Sergei 8af7a11cd9 Fix WiFi credentials cache implementation in SecureStore
- Fix saveWiFiPassword to use encrypted passwords map instead of decrypted
- Fix getWiFiPassword to decrypt from encrypted storage
- Fix test expectations for migration and encryption functions
- Remove unused error variables to fix linting warnings
- All 27 tests now passing with proper encryption/decryption flow

The WiFi credentials cache feature was already implemented but had bugs
where encrypted and decrypted password maps were being mixed. This commit
ensures proper encryption is maintained throughout the storage lifecycle.
2026-01-31 15:55:24 -08:00

972 lines
31 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 { 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);
// Default: show WebView dashboard (real data), toggle enables Mock Data
const [showWebView, setShowWebView] = useState(true);
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<string | null>(null);
// AbortController to cancel in-flight requests when component unmounts or ID changes
const abortControllerRef = useRef<AbortController | null>(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<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));
// Ensure is_mobile flag is set (hides navigation in WebView)
localStorage.setItem('is_mobile', '1');
})();
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}
// Also sets is_mobile flag to hide navigation bar (like in Lite version)
const injectedJavaScript = legacyCredentials
? `
(function() {
try {
var authData = {
username: '${legacyCredentials.userName}',
token: '${legacyCredentials.token}',
user_id: ${legacyCredentials.userId}
};
localStorage.setItem('auth2', JSON.stringify(authData));
// Set is_mobile flag to hide navigation bar in WebView
localStorage.setItem('is_mobile', '1');
} catch(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
key={beneficiary.avatar}
source={{ uri: beneficiary.avatar }}
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);
}}>
<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>
)}
{/* Mock Data Toggle - inverted: true = show MockDashboard, false = show WebView */}
<View style={styles.devToggleSection}>
<DevModeToggle value={!showWebView} onValueChange={(val) => setShowWebView(!val)} />
</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
key={editForm.avatar}
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: {
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,
},
});