- 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.
972 lines
31 KiB
TypeScript
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,
|
|
},
|
|
});
|