Sergei 7feca4d54b Add debouncing for refresh buttons to prevent duplicate API calls
Implemented a reusable useDebounce hook to prevent rapid-fire clicks
on refresh buttons throughout the application.

Changes:
- Created hooks/useDebounce.ts with configurable delay and leading/trailing edge options
- Added comprehensive unit tests in hooks/__tests__/useDebounce.test.ts
- Applied debouncing to dashboard WebView refresh button (app/(tabs)/dashboard.tsx)
- Applied debouncing to beneficiary detail pull-to-refresh (app/(tabs)/beneficiaries/[id]/index.tsx)
- Applied debouncing to equipment screen refresh (app/(tabs)/beneficiaries/[id]/equipment.tsx)
- Applied debouncing to all error retry buttons (components/ui/ErrorMessage.tsx)
- Fixed jest.setup.js to properly mock React Native modules
- Added implementation documentation in docs/DEBOUNCE_IMPLEMENTATION.md

Technical details:
- Default 1-second debounce delay
- Leading edge execution (immediate first call, then debounce)
- Type-safe with TypeScript generics
- Automatic cleanup on component unmount

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-29 11:44:16 -08:00

913 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';
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<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) {
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;
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 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 <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: bustImageCache(beneficiary.avatar) || undefined }} style={styles.headerAvatarImage} />
) : (
<View style={styles.headerAvatar}>
<Text style={styles.headerAvatarText}>
{beneficiary.displayName.charAt(0).toUpperCase()}
</Text>
</View>
)}
</TouchableOpacity>
<Text style={styles.headerTitle} numberOfLines={1}>{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) => {
// Message received from WebView
}}
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: bustImageCache(editForm.avatar) || undefined }} 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={bustImageCache(beneficiary?.avatar)}
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: {
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,
},
});