- Add legacy dashboard API methods (eluxnetworks.net) - Implement JWT token validation before using cached credentials - Clear invalid tokens (non-JWT strings like "0") and force re-login - Use correct credentials (anandk/anandk_8) - Add 30-minute token refresh interval when WebView is active - Fix avatar upload using expo-file-system instead of FileReader - Handle address field as both string and object
748 lines
23 KiB
TypeScript
748 lines
23 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';
|
|
|
|
// 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 } = useLocalSearchParams<{ id: 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 });
|
|
|
|
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]);
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
setIsRefreshing(true);
|
|
loadBeneficiary(false);
|
|
}, [loadBeneficiary]);
|
|
|
|
const handleEditPress = () => {
|
|
if (beneficiary) {
|
|
setEditForm({
|
|
name: beneficiary.name || '',
|
|
address: beneficiary.address || '',
|
|
avatar: beneficiary.avatar,
|
|
});
|
|
setIsEditModalVisible(true);
|
|
}
|
|
};
|
|
|
|
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 (!editForm.name.trim() || !id) {
|
|
toast.error('Error', 'Name is required');
|
|
return;
|
|
}
|
|
|
|
const beneficiaryId = parseInt(id, 10);
|
|
|
|
try {
|
|
// Update basic info
|
|
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.');
|
|
return;
|
|
}
|
|
|
|
// Upload avatar if changed (new local file URI)
|
|
if (editForm.avatar && editForm.avatar.startsWith('file://')) {
|
|
const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar);
|
|
if (!avatarResult.ok) {
|
|
console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message);
|
|
// Show info but don't fail the whole operation
|
|
toast.info('Note', 'Profile saved but avatar upload failed');
|
|
}
|
|
}
|
|
|
|
setIsEditModalVisible(false);
|
|
toast.success('Saved', 'Profile updated successfully');
|
|
loadBeneficiary(false);
|
|
} catch (err) {
|
|
toast.error('Error', 'Failed to save changes.');
|
|
}
|
|
};
|
|
|
|
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 */}
|
|
<View style={styles.headerCenter}>
|
|
{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>
|
|
)}
|
|
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
|
</View>
|
|
|
|
<BeneficiaryMenu
|
|
beneficiaryId={id || ''}
|
|
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.name} />
|
|
</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}>Edit Profile</Text>
|
|
<TouchableOpacity onPress={() => setIsEditModalVisible(false)}>
|
|
<Ionicons name="close" size={24} color={AppColors.textPrimary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView style={styles.modalContent}>
|
|
{/* Avatar */}
|
|
<TouchableOpacity style={styles.avatarPicker} onPress={handlePickAvatar}>
|
|
{editForm.avatar ? (
|
|
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
|
|
) : (
|
|
<View style={styles.avatarPickerPlaceholder}>
|
|
<Ionicons name="camera" size={32} color={AppColors.textMuted} />
|
|
</View>
|
|
)}
|
|
<View style={styles.avatarPickerBadge}>
|
|
<Ionicons name="pencil" size={12} color={AppColors.white} />
|
|
</View>
|
|
</TouchableOpacity>
|
|
|
|
{/* Name */}
|
|
<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>
|
|
|
|
{/* Address */}
|
|
<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>
|
|
</ScrollView>
|
|
|
|
<View style={styles.modalFooter}>
|
|
<TouchableOpacity
|
|
style={styles.cancelButton}
|
|
onPress={() => setIsEditModalVisible(false)}
|
|
>
|
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.saveButton} onPress={handleSaveEdit}>
|
|
<Text style={styles.saveButtonText}>Save</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
</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,
|
|
},
|
|
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,
|
|
},
|
|
});
|