- Add displayName field to Beneficiary type (computed: customName || name) - Populate displayName in getAllBeneficiaries and getWellNuoBeneficiary API calls - Update detail page header to use beneficiary.displayName - Update MockDashboard to use displayName 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
911 lines
29 KiB
TypeScript
911 lines
29 KiB
TypeScript
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';
|
|
|
|
// 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<Beneficiary | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [error, setError] = useState<string | null>(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);
|
|
|
|
// 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<WebView>(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) {
|
|
console.log('[DevMode] Legacy token expiring, refreshing...');
|
|
await api.refreshLegacyToken();
|
|
}
|
|
|
|
const credentials = await api.getLegacyWebViewCredentials();
|
|
if (credentials) {
|
|
setLegacyCredentials(credentials);
|
|
console.log('[DevMode] Legacy credentials loaded:', credentials.userName);
|
|
}
|
|
setIsWebViewReady(true);
|
|
} catch (err) {
|
|
console.log('[DevMode] Failed to load legacy credentials:', 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) {
|
|
console.log('[DevMode] Periodic check: refreshing legacy token...');
|
|
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;
|
|
|
|
if (showLoadingIndicator && !isRefreshing) {
|
|
setIsLoading(true);
|
|
}
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
|
|
if (response.ok && response.data) {
|
|
const data = response.data;
|
|
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 {
|
|
setError(response.error?.message || 'Failed to load beneficiary');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
}, [id, setCurrentBeneficiary, isRefreshing]);
|
|
|
|
useEffect(() => {
|
|
loadBeneficiary();
|
|
}, [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 handleRefresh = useCallback(() => {
|
|
setIsRefreshing(true);
|
|
loadBeneficiary(false);
|
|
}, [loadBeneficiary]);
|
|
|
|
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) {
|
|
console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message);
|
|
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');
|
|
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 <LoadingSpinner fullScreen message="Loading..." />;
|
|
}
|
|
|
|
if (error || !beneficiary) {
|
|
return (
|
|
<FullScreenError
|
|
message={error || 'Beneficiary not found'}
|
|
onRetry={() => loadBeneficiary()}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<TouchableOpacity style={styles.headerButton} onPress={() => router.replace('/(tabs)')}>
|
|
<Ionicons name="arrow-back" size={22} color={AppColors.textPrimary} />
|
|
</TouchableOpacity>
|
|
|
|
{/* Avatar + Name + Role */}
|
|
<View style={styles.headerCenter}>
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
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') ? (
|
|
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
|
|
) : (
|
|
<View style={styles.headerAvatar}>
|
|
<Text style={styles.headerAvatarText}>
|
|
{beneficiary.name.charAt(0).toUpperCase()}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
<Text style={styles.headerTitle}>{beneficiary.displayName}</Text>
|
|
</View>
|
|
|
|
<BeneficiaryMenu
|
|
beneficiaryId={id || ''}
|
|
userRole={beneficiary?.role}
|
|
onEdit={handleEditPress}
|
|
onRemove={handleDeleteBeneficiary}
|
|
/>
|
|
</View>
|
|
|
|
{/* DEBUG PANEL - commented out
|
|
{__DEV__ && (
|
|
<View style={styles.debugPanel}>
|
|
<Text style={styles.debugTitle}>DEBUG INFO (tap to copy)</Text>
|
|
<TouchableOpacity onPress={() => {
|
|
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);
|
|
}}>
|
|
<Text style={styles.debugText}>hasDevices: {String(hasBeneficiaryDevices(beneficiary))}</Text>
|
|
<Text style={styles.debugText}>equipmentStatus: {beneficiary.equipmentStatus || 'none'}</Text>
|
|
<Text style={styles.debugText}>subscription: {beneficiary.subscription ? JSON.stringify(beneficiary.subscription) : 'none'}</Text>
|
|
<Text style={styles.debugText}>device_id: {beneficiary.device_id || 'null'}</Text>
|
|
<Text style={styles.debugText}>devices: {beneficiary.devices ? String(beneficiary.devices) : '0'}</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
*/}
|
|
|
|
{/* Dashboard Content */}
|
|
<View style={styles.dashboardContainer}>
|
|
{shouldShowSubscriptionWarning(beneficiary) && (
|
|
<View style={styles.subscriptionWarning}>
|
|
<Ionicons name="alert-circle" size={16} color={AppColors.warning} />
|
|
<Text style={styles.subscriptionWarningText}>
|
|
Subscription expiring soon
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Developer Toggle */}
|
|
<View style={styles.devToggleSection}>
|
|
<DevModeToggle value={showWebView} onValueChange={setShowWebView} />
|
|
</View>
|
|
|
|
{/* Content area - WebView or MockDashboard */}
|
|
<View style={styles.dashboardContent}>
|
|
{showWebView ? (
|
|
isWebViewReady && legacyCredentials ? (
|
|
<WebView
|
|
ref={webViewRef}
|
|
source={{ uri: getDashboardUrl(api.getDemoDeploymentId()) }}
|
|
style={styles.webView}
|
|
javaScriptEnabled={true}
|
|
domStorageEnabled={true}
|
|
startInLoadingState={true}
|
|
allowsBackForwardNavigationGestures={true}
|
|
injectedJavaScriptBeforeContentLoaded={injectedJavaScript}
|
|
injectedJavaScript={injectedJavaScript}
|
|
onMessage={(event) => {
|
|
console.log('[WebView] Message:', event.nativeEvent.data);
|
|
}}
|
|
renderLoading={() => (
|
|
<View style={styles.webViewLoading}>
|
|
<ActivityIndicator size="large" color={AppColors.primary} />
|
|
<Text style={styles.webViewLoadingText}>Loading dashboard...</Text>
|
|
</View>
|
|
)}
|
|
/>
|
|
) : (
|
|
<View style={styles.webViewLoading}>
|
|
<ActivityIndicator size="large" color={AppColors.primary} />
|
|
<Text style={styles.webViewLoadingText}>
|
|
{isWebViewReady ? 'Authenticating...' : 'Connecting to sensors...'}
|
|
</Text>
|
|
</View>
|
|
)
|
|
) : (
|
|
<ScrollView
|
|
style={styles.nativeScrollView}
|
|
contentContainerStyle={styles.nativeScrollContent}
|
|
showsVerticalScrollIndicator={false}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={isRefreshing}
|
|
onRefresh={handleRefresh}
|
|
tintColor={AppColors.primary}
|
|
/>
|
|
}
|
|
>
|
|
<MockDashboard beneficiaryName={beneficiary.displayName} />
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Edit Modal */}
|
|
<Modal visible={isEditModalVisible} animationType="slide" transparent>
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
style={styles.modalOverlay}
|
|
>
|
|
<View style={styles.modalContainer}>
|
|
<View style={styles.modalHeader}>
|
|
<Text style={styles.modalTitle}>
|
|
{isCustodian ? 'Edit Profile' : 'Edit Nickname'}
|
|
</Text>
|
|
<TouchableOpacity onPress={() => setIsEditModalVisible(false)}>
|
|
<Ionicons name="close" size={24} color={AppColors.textPrimary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView style={styles.modalContent}>
|
|
{isCustodian ? (
|
|
<>
|
|
{/* Custodian: Avatar, Name, Address */}
|
|
<TouchableOpacity
|
|
style={styles.avatarPicker}
|
|
onPress={handlePickAvatar}
|
|
disabled={isSavingEdit}
|
|
>
|
|
{editForm.avatar ? (
|
|
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
|
|
) : (
|
|
<View style={styles.avatarPickerPlaceholder}>
|
|
<Ionicons name="camera" size={32} color={AppColors.textMuted} />
|
|
</View>
|
|
)}
|
|
{isUploadingAvatar && (
|
|
<View style={styles.avatarUploadOverlay}>
|
|
<ActivityIndicator size="large" color={AppColors.white} />
|
|
<Text style={styles.avatarUploadText}>Uploading...</Text>
|
|
</View>
|
|
)}
|
|
{!isUploadingAvatar && (
|
|
<View style={styles.avatarPickerBadge}>
|
|
<Ionicons name="pencil" size={12} color={AppColors.white} />
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
<View style={styles.inputGroup}>
|
|
<Text style={styles.inputLabel}>Name</Text>
|
|
<TextInput
|
|
style={styles.textInput}
|
|
value={editForm.name}
|
|
onChangeText={(text) => setEditForm(prev => ({ ...prev, name: text }))}
|
|
placeholder="Full name"
|
|
placeholderTextColor={AppColors.textMuted}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.inputGroup}>
|
|
<Text style={styles.inputLabel}>Address</Text>
|
|
<TextInput
|
|
style={[styles.textInput, styles.textArea]}
|
|
value={editForm.address}
|
|
onChangeText={(text) => setEditForm(prev => ({ ...prev, address: text }))}
|
|
placeholder="Street address"
|
|
placeholderTextColor={AppColors.textMuted}
|
|
multiline
|
|
numberOfLines={3}
|
|
/>
|
|
</View>
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Guardian/Caretaker: Only custom nickname */}
|
|
<View style={styles.nicknameInfo}>
|
|
<Text style={styles.nicknameInfoText}>
|
|
Set a personal nickname for {beneficiary?.name}. This is only visible to you.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.inputGroup}>
|
|
<Text style={styles.inputLabel}>Nickname</Text>
|
|
<TextInput
|
|
style={styles.textInput}
|
|
value={editForm.customName}
|
|
onChangeText={(text) => setEditForm(prev => ({ ...prev, customName: text }))}
|
|
placeholder={`e.g., "Mom", "Dad", "Grandma"`}
|
|
placeholderTextColor={AppColors.textMuted}
|
|
maxLength={100}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.originalNameContainer}>
|
|
<Text style={styles.originalNameLabel}>Original name:</Text>
|
|
<Text style={styles.originalNameValue}>{beneficiary?.name}</Text>
|
|
</View>
|
|
</>
|
|
)}
|
|
</ScrollView>
|
|
|
|
<View style={styles.modalFooter}>
|
|
<TouchableOpacity
|
|
style={[styles.cancelButton, isSavingEdit && styles.buttonDisabled]}
|
|
onPress={() => setIsEditModalVisible(false)}
|
|
disabled={isSavingEdit}
|
|
>
|
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.saveButton, isSavingEdit && styles.buttonDisabled]}
|
|
onPress={handleSaveEdit}
|
|
disabled={isSavingEdit}
|
|
>
|
|
{isSavingEdit ? (
|
|
<ActivityIndicator size="small" color={AppColors.white} />
|
|
) : (
|
|
<Text style={styles.saveButtonText}>Save</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
|
|
{/* Avatar Lightbox */}
|
|
<ImageLightbox
|
|
visible={lightboxVisible}
|
|
imageUri={beneficiary?.avatar || null}
|
|
onClose={() => setLightboxVisible(false)}
|
|
/>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
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: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 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,
|
|
},
|
|
});
|