import React, { useState, useEffect, useCallback, useRef } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl, Image, Modal, TextInput, Alert, KeyboardAvoidingView, Platform, ActivityIndicator, } from 'react-native'; import { WebView } from 'react-native-webview'; import { useLocalSearchParams, router } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; import { api } from '@/services/api'; import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { FullScreenError } from '@/components/ui/ErrorMessage'; import { useToast } from '@/components/ui/Toast'; import { DevModeToggle } from '@/components/ui/DevModeToggle'; import MockDashboard from '@/components/MockDashboard'; import { AppColors, BorderRadius, FontSizes, Spacing, FontWeights, Shadows, AvatarSizes, } from '@/constants/theme'; import type { Beneficiary } from '@/types'; import { hasBeneficiaryDevices, hasActiveSubscription, shouldShowSubscriptionWarning, } from '@/services/BeneficiaryDetailController'; import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu'; import { ImageLightbox } from '@/components/ImageLightbox'; import { bustImageCache } from '@/utils/imageUtils'; import { useDebounce } from '@/hooks/useDebounce'; // WebView Dashboard URL - opens specific deployment directly const getDashboardUrl = (deploymentId?: number) => { const baseUrl = 'https://react.eluxnetworks.net/dashboard'; return deploymentId ? `${baseUrl}/${deploymentId}` : `${baseUrl}/21`; }; // Ferdinand's default deployment ID (has sensor data) const FERDINAND_DEPLOYMENT_ID = 21; export default function BeneficiaryDetailScreen() { const { id, edit } = useLocalSearchParams<{ id: string; edit?: string }>(); const { setCurrentBeneficiary } = useBeneficiary(); const toast = useToast(); const [beneficiary, setBeneficiary] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); const [showWebView, setShowWebView] = useState(false); const [isWebViewReady, setIsWebViewReady] = useState(false); const [legacyCredentials, setLegacyCredentials] = useState<{ token: string; userName: string; userId: string; } | null>(null); const [isRefreshingToken, setIsRefreshingToken] = useState(false); // Track which beneficiary ID is currently being loaded to prevent race conditions const loadingBeneficiaryIdRef = useRef(null); // AbortController to cancel in-flight requests when component unmounts or ID changes const abortControllerRef = useRef(null); // Edit modal state const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [editForm, setEditForm] = useState({ name: '', address: '', avatar: undefined as string | undefined, customName: '' // For non-custodian users }); const [isSavingEdit, setIsSavingEdit] = useState(false); const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); // Avatar lightbox state const [lightboxVisible, setLightboxVisible] = useState(false); const webViewRef = useRef(null); // Load legacy credentials for WebView dashboard const loadLegacyCredentials = useCallback(async () => { try { // Check if token is expiring soon const isExpiring = await api.isLegacyTokenExpiringSoon(); if (isExpiring) { await api.refreshLegacyToken(); } const credentials = await api.getLegacyWebViewCredentials(); if (credentials) { setLegacyCredentials(credentials); } setIsWebViewReady(true); } catch (err) { setIsWebViewReady(true); } }, []); useEffect(() => { loadLegacyCredentials(); // Periodically refresh token (every 30 minutes) const tokenCheckInterval = setInterval(async () => { if (!showWebView) return; // Only refresh if WebView is active const isExpiring = await api.isLegacyTokenExpiringSoon(); if (isExpiring && !isRefreshingToken) { setIsRefreshingToken(true); const result = await api.refreshLegacyToken(); if (result.ok) { const credentials = await api.getLegacyWebViewCredentials(); if (credentials) { setLegacyCredentials(credentials); // Re-inject token into WebView const injectScript = ` (function() { var authData = { username: '${credentials.userName}', token: '${credentials.token}', user_id: ${credentials.userId} }; localStorage.setItem('auth2', JSON.stringify(authData)); console.log('Token auto-refreshed'); })(); true; `; webViewRef.current?.injectJavaScript(injectScript); } } setIsRefreshingToken(false); } }, 30 * 60 * 1000); // 30 minutes return () => clearInterval(tokenCheckInterval); }, [loadLegacyCredentials, showWebView, isRefreshingToken]); const loadBeneficiary = useCallback(async (showLoadingIndicator = true) => { if (!id) return; // Cancel any previous in-flight request if (abortControllerRef.current) { abortControllerRef.current.abort(); } // Create new AbortController for this request const abortController = new AbortController(); abortControllerRef.current = abortController; // Track which beneficiary we're loading const currentLoadingId = id; loadingBeneficiaryIdRef.current = currentLoadingId; if (showLoadingIndicator && !isRefreshing) { setIsLoading(true); } setError(null); try { const response = await api.getWellNuoBeneficiary(parseInt(id, 10)); // Check if this request was cancelled or a newer request started if (abortController.signal.aborted || loadingBeneficiaryIdRef.current !== currentLoadingId) { // This request is stale, ignore its results return; } if (response.ok && response.data) { const data = response.data; // Double-check ID still matches before updating state if (loadingBeneficiaryIdRef.current !== currentLoadingId) { return; } setBeneficiary(data); setCurrentBeneficiary(data); // WATERFALL REDIRECT LOGIC: // 1. First check devices (highest priority) if (!hasBeneficiaryDevices(data)) { const status = data.equipmentStatus; if (status && ['ordered', 'shipped', 'delivered'].includes(status)) { // Equipment is ordered/shipped/delivered → show status router.replace(`/(tabs)/beneficiaries/${id}/equipment-status`); return; } else { // No devices and no order → need to purchase router.replace(`/(tabs)/beneficiaries/${id}/purchase`); return; } } // 2. Then check subscription (only if devices exist) if (!hasActiveSubscription(data)) { router.replace(`/(tabs)/beneficiaries/${id}/subscription`); return; } // 3. All good → show Dashboard (this screen) } else { if (loadingBeneficiaryIdRef.current === currentLoadingId) { setError(response.error?.message || 'Failed to load beneficiary'); } } } catch (err) { // Only update error if this request is still current if (!abortController.signal.aborted && loadingBeneficiaryIdRef.current === currentLoadingId) { setError(err instanceof Error ? err.message : 'An error occurred'); } } finally { // Only clear loading if this request is still current if (!abortController.signal.aborted && loadingBeneficiaryIdRef.current === currentLoadingId) { setIsLoading(false); setIsRefreshing(false); } } }, [id, setCurrentBeneficiary, isRefreshing]); useEffect(() => { loadBeneficiary(); // Cleanup: cancel any in-flight requests when component unmounts or ID changes return () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; } loadingBeneficiaryIdRef.current = null; }; }, [loadBeneficiary]); // Auto-open edit modal if navigated with ?edit=true parameter useEffect(() => { if (edit === 'true' && beneficiary && !isLoading && !isEditModalVisible) { handleEditPress(); // Clear the edit param to prevent re-opening on future navigations router.setParams({ edit: undefined }); } }, [edit, beneficiary, isLoading, isEditModalVisible]); const handleRefreshInternal = useCallback(() => { setIsRefreshing(true); loadBeneficiary(false); }, [loadBeneficiary]); const { debouncedFn: handleRefresh } = useDebounce(handleRefreshInternal, { delay: 1000 }); const handleEditPress = () => { if (beneficiary) { setEditForm({ name: beneficiary.name || '', address: beneficiary.address || '', avatar: beneficiary.avatar, customName: beneficiary.customName || '', }); setIsEditModalVisible(true); } }; // Check if user is custodian (can edit all fields) const isCustodian = beneficiary?.role === 'custodian'; const handlePickAvatar = async () => { const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== 'granted') { toast.error('Permission needed', 'Please allow access to your photo library.'); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, aspect: [1, 1], quality: 0.5, }); if (!result.canceled && result.assets[0]) { setEditForm(prev => ({ ...prev, avatar: result.assets[0].uri })); } }; const handleSaveEdit = async () => { if (!id) { toast.error('Error', 'Invalid beneficiary'); return; } const beneficiaryId = parseInt(id, 10); setIsSavingEdit(true); try { if (isCustodian) { // Custodian: update name, address in beneficiaries table if (!editForm.name.trim()) { toast.error('Error', 'Name is required'); setIsSavingEdit(false); return; } const response = await api.updateWellNuoBeneficiary(beneficiaryId, { name: editForm.name.trim(), address: editForm.address.trim() || undefined, }); if (!response.ok) { toast.error('Error', response.error?.message || 'Failed to save changes.'); setIsSavingEdit(false); return; } // Upload avatar if changed (new local file URI) if (editForm.avatar && editForm.avatar.startsWith('file://')) { setIsUploadingAvatar(true); const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar); setIsUploadingAvatar(false); if (!avatarResult.ok) { toast.info('Note', 'Profile saved but avatar upload failed'); } } } else { // Guardian/Caretaker: update only customName in user_access table const response = await api.updateBeneficiaryCustomName( beneficiaryId, editForm.customName.trim() || null ); if (!response.ok) { toast.error('Error', response.error?.message || 'Failed to save nickname.'); setIsSavingEdit(false); return; } } setIsEditModalVisible(false); toast.success('Saved', isCustodian ? 'Profile updated successfully' : 'Nickname saved'); // Reload to get updated data with fresh avatar URL (cache-busting timestamp will be applied) await loadBeneficiary(false); } catch (err) { toast.error('Error', 'Failed to save changes.'); } finally { setIsSavingEdit(false); setIsUploadingAvatar(false); } }; const handleDeleteBeneficiary = () => { if (!id) return; Alert.alert( 'Remove Beneficiary', `Are you sure you want to remove ${beneficiary?.name}? This action cannot be undone.`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Remove', style: 'destructive', onPress: async () => { try { const response = await api.deleteBeneficiary(parseInt(id, 10)); if (response.ok) { toast.success('Removed', 'Beneficiary has been removed'); router.replace('/(tabs)'); } else { toast.error('Error', response.error?.message || 'Failed to remove beneficiary'); } } catch (err) { toast.error('Error', 'Failed to remove beneficiary'); } }, }, ] ); }; // JavaScript to inject token into localStorage for WebView // Web app expects auth2 as JSON: {username, token, user_id} const injectedJavaScript = legacyCredentials ? ` (function() { try { var authData = { username: '${legacyCredentials.userName}', token: '${legacyCredentials.token}', user_id: ${legacyCredentials.userId} }; localStorage.setItem('auth2', JSON.stringify(authData)); console.log('Auth data injected:', authData.username); } catch(e) { console.error('Failed to inject token:', e); } })(); true; ` : ''; if (isLoading) { return ; } if (error || !beneficiary) { return ( loadBeneficiary()} /> ); } return ( {/* Header */} router.replace('/(tabs)')}> {/* Avatar + Name + Role */} { if (beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder')) { setLightboxVisible(true); } }} disabled={!beneficiary.avatar || beneficiary.avatar.trim() === '' || beneficiary.avatar.includes('placeholder')} > {beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder') ? ( ) : ( {beneficiary.displayName.charAt(0).toUpperCase()} )} {beneficiary.displayName} {/* DEBUG PANEL - commented out {__DEV__ && ( DEBUG INFO (tap to copy) { const debugData = JSON.stringify({ hasDevices: hasBeneficiaryDevices(beneficiary), equipmentStatus: beneficiary.equipmentStatus, subscription: beneficiary.subscription, deviceId: beneficiary.device_id, devices: beneficiary.devices, }, null, 2); console.log('DEBUG DATA:', debugData); }}> hasDevices: {String(hasBeneficiaryDevices(beneficiary))} equipmentStatus: {beneficiary.equipmentStatus || 'none'} subscription: {beneficiary.subscription ? JSON.stringify(beneficiary.subscription) : 'none'} device_id: {beneficiary.device_id || 'null'} devices: {beneficiary.devices ? String(beneficiary.devices) : '0'} )} */} {/* Dashboard Content */} {shouldShowSubscriptionWarning(beneficiary) && ( Subscription expiring soon )} {/* Developer Toggle */} {/* Content area - WebView or MockDashboard */} {showWebView ? ( isWebViewReady && legacyCredentials ? ( { // Message received from WebView }} renderLoading={() => ( Loading dashboard... )} /> ) : ( {isWebViewReady ? 'Authenticating...' : 'Connecting to sensors...'} ) ) : ( } > )} {/* Edit Modal */} {isCustodian ? 'Edit Profile' : 'Edit Nickname'} setIsEditModalVisible(false)}> {isCustodian ? ( <> {/* Custodian: Avatar, Name, Address */} {editForm.avatar ? ( ) : ( )} {isUploadingAvatar && ( Uploading... )} {!isUploadingAvatar && ( )} Name setEditForm(prev => ({ ...prev, name: text }))} placeholder="Full name" placeholderTextColor={AppColors.textMuted} /> Address setEditForm(prev => ({ ...prev, address: text }))} placeholder="Street address" placeholderTextColor={AppColors.textMuted} multiline numberOfLines={3} /> ) : ( <> {/* Guardian/Caretaker: Only custom nickname */} Set a personal nickname for {beneficiary?.name}. This is only visible to you. Nickname setEditForm(prev => ({ ...prev, customName: text }))} placeholder={`e.g., "Mom", "Dad", "Grandma"`} placeholderTextColor={AppColors.textMuted} maxLength={100} /> Original name: {beneficiary?.name} )} setIsEditModalVisible(false)} disabled={isSavingEdit} > Cancel {isSavingEdit ? ( ) : ( Save )} {/* Avatar Lightbox */} setLightboxVisible(false)} /> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: AppColors.background, }, // Header header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: Spacing.md, paddingVertical: Spacing.sm, borderBottomWidth: 1, borderBottomColor: AppColors.border, backgroundColor: AppColors.surface, zIndex: 10, }, headerButton: { width: 36, height: 36, borderRadius: BorderRadius.md, justifyContent: 'center', alignItems: 'center', }, headerCenter: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, marginHorizontal: Spacing.sm, }, headerAvatar: { width: AvatarSizes.sm, height: AvatarSizes.sm, borderRadius: AvatarSizes.sm / 2, backgroundColor: AppColors.primary, justifyContent: 'center', alignItems: 'center', }, headerAvatarText: { fontSize: FontSizes.base, fontWeight: FontWeights.bold, color: AppColors.white, }, headerAvatarImage: { width: AvatarSizes.sm, height: AvatarSizes.sm, borderRadius: AvatarSizes.sm / 2, }, headerTitle: { fontSize: FontSizes.lg, fontWeight: FontWeights.semibold, color: AppColors.textPrimary, }, // Debug Panel debugPanel: { backgroundColor: '#FFF9C4', padding: Spacing.sm, borderBottomWidth: 1, borderBottomColor: '#F9A825', }, debugTitle: { fontSize: FontSizes.xs, fontWeight: FontWeights.bold, color: '#F57F17', marginBottom: 4, }, debugText: { fontSize: FontSizes.xs, color: '#5D4037', fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', }, // Dashboard dashboardContainer: { flex: 1, }, subscriptionWarning: { flexDirection: 'row', alignItems: 'center', backgroundColor: AppColors.warningLight, paddingHorizontal: Spacing.md, paddingVertical: Spacing.sm, gap: Spacing.xs, }, subscriptionWarningText: { fontSize: FontSizes.sm, color: AppColors.warning, fontWeight: FontWeights.medium, }, devToggleSection: { paddingHorizontal: Spacing.md, paddingVertical: Spacing.sm, backgroundColor: AppColors.surface, borderBottomWidth: 1, borderBottomColor: AppColors.border, }, dashboardContent: { flex: 1, }, webView: { flex: 1, }, webViewLoading: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: Spacing.md, }, webViewLoadingText: { fontSize: FontSizes.base, color: AppColors.textSecondary, }, nativeScrollView: { flex: 1, }, nativeScrollContent: { padding: Spacing.md, }, // Edit Modal modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'flex-end', }, modalContainer: { backgroundColor: AppColors.surface, borderTopLeftRadius: BorderRadius.xl, borderTopRightRadius: BorderRadius.xl, maxHeight: '80%', }, modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: Spacing.md, borderBottomWidth: 1, borderBottomColor: AppColors.border, }, modalTitle: { fontSize: FontSizes.lg, fontWeight: FontWeights.bold, color: AppColors.textPrimary, }, modalContent: { padding: Spacing.lg, }, avatarPicker: { alignSelf: 'center', marginBottom: Spacing.lg, }, avatarPickerImage: { width: 100, height: 100, borderRadius: 50, }, avatarPickerPlaceholder: { width: 100, height: 100, borderRadius: 50, backgroundColor: AppColors.surfaceSecondary, justifyContent: 'center', alignItems: 'center', }, avatarPickerBadge: { position: 'absolute', bottom: 0, right: 0, width: 28, height: 28, borderRadius: 14, backgroundColor: AppColors.primary, justifyContent: 'center', alignItems: 'center', borderWidth: 2, borderColor: AppColors.surface, }, avatarUploadOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.6)', borderRadius: AvatarSizes.lg / 2, justifyContent: 'center', alignItems: 'center', }, avatarUploadText: { color: AppColors.white, fontSize: FontSizes.sm, marginTop: Spacing.xs, }, inputGroup: { marginBottom: Spacing.md, }, inputLabel: { fontSize: FontSizes.sm, fontWeight: FontWeights.medium, color: AppColors.textSecondary, marginBottom: Spacing.xs, }, textInput: { backgroundColor: AppColors.surfaceSecondary, borderRadius: BorderRadius.md, padding: Spacing.md, fontSize: FontSizes.base, color: AppColors.textPrimary, }, textArea: { minHeight: 80, textAlignVertical: 'top', }, modalFooter: { flexDirection: 'row', padding: Spacing.md, gap: Spacing.md, borderTopWidth: 1, borderTopColor: AppColors.border, }, cancelButton: { flex: 1, padding: Spacing.md, borderRadius: BorderRadius.md, backgroundColor: AppColors.surfaceSecondary, alignItems: 'center', }, cancelButtonText: { fontSize: FontSizes.base, fontWeight: FontWeights.semibold, color: AppColors.textSecondary, }, saveButton: { flex: 1, padding: Spacing.md, borderRadius: BorderRadius.md, backgroundColor: AppColors.primary, alignItems: 'center', }, saveButtonText: { fontSize: FontSizes.base, fontWeight: FontWeights.semibold, color: AppColors.white, }, buttonDisabled: { opacity: 0.6, }, // Non-custodian edit modal styles nicknameInfo: { backgroundColor: AppColors.surfaceSecondary, padding: Spacing.md, borderRadius: BorderRadius.md, marginBottom: Spacing.lg, }, nicknameInfoText: { fontSize: FontSizes.sm, color: AppColors.textSecondary, lineHeight: 20, }, originalNameContainer: { flexDirection: 'row', alignItems: 'center', marginTop: Spacing.sm, paddingTop: Spacing.md, borderTopWidth: 1, borderTopColor: AppColors.border, }, originalNameLabel: { fontSize: FontSizes.sm, color: AppColors.textMuted, marginRight: Spacing.xs, }, originalNameValue: { fontSize: FontSizes.sm, color: AppColors.textSecondary, fontWeight: FontWeights.medium, }, });