Sergei 20be9a94c2 WIP: Navigation controller, subscription flow, and various improvements
- Add NavigationController for centralized routing logic
- Add useNavigationFlow hook for easy usage in components
- Update subscription flow with Stripe integration
- Simplify activate.tsx
- Update beneficiaries and profile screens
- Update CLAUDE.md with navigation documentation
2026-01-04 12:53:38 -08:00

1815 lines
52 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
RefreshControl,
Image,
Modal,
TextInput,
Alert,
KeyboardAvoidingView,
Platform,
Animated,
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 { usePaymentSheet } from '@stripe/stripe-react-native';
import { api } from '@/services/api';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useAuth } from '@/contexts/AuthContext';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { FullScreenError } from '@/components/ui/ErrorMessage';
import { Button } from '@/components/ui/Button';
import { SubscriptionPayment } from '@/components/SubscriptionPayment';
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';
// Setup state types
type SetupState = 'loading' | 'awaiting_equipment' | 'no_devices' | 'no_subscription' | 'ready';
// Stripe API
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
// WebView Dashboard URL - opens specific deployment directly
// Format: /dashboard/{deployment_id} skips the user list and shows the dashboard
const getDashboardUrl = (deploymentId?: number) => {
const baseUrl = 'https://react.eluxnetworks.net/dashboard';
// Default to Ferdinand's deployment (21) if no specific deployment
return deploymentId ? `${baseUrl}/${deploymentId}` : `${baseUrl}/21`;
};
// Test credentials for WebView - anandk account has real sensor data
const TEST_NDK_USER = 'anandk';
const TEST_NDK_PASSWORD = 'anandk_8';
// Ferdinand's default deployment ID (has sensor data)
const FERDINAND_DEPLOYMENT_ID = 21;
// Starter Kit info
const STARTER_KIT = {
name: 'WellNuo Starter Kit',
price: '$249',
priceValue: 249,
features: [
'Motion sensor (PIR)',
'Door/window sensor',
'Temperature & humidity sensor',
'WellNuo Hub',
'Mobile app access',
'1 year subscription included',
],
};
// No Devices Screen Component - Primary: Buy Kit with Stripe, Secondary: I have sensors
function NoDevicesScreen({
beneficiary,
beneficiaryId,
onActivate,
onPurchaseSuccess,
userEmail,
userId,
}: {
beneficiary: Beneficiary;
beneficiaryId: string;
onActivate: () => void;
onPurchaseSuccess: () => void;
userEmail?: string;
userId?: string;
}) {
const [isProcessing, setIsProcessing] = useState(false);
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const toast = useToast();
const handlePurchase = async () => {
setIsProcessing(true);
try {
// 1. Create Payment Sheet on server
const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: userEmail || 'guest@wellnuo.com',
amount: STARTER_KIT.priceValue * 100, // Convert to cents ($249.00)
metadata: {
userId: userId || 'guest',
beneficiaryName: beneficiary.name,
beneficiaryId: beneficiaryId,
},
}),
});
const data = await response.json();
if (!data.paymentIntent) {
throw new Error(data.error || 'Failed to create payment sheet');
}
// 2. Initialize the Payment Sheet
const { error: initError } = await initPaymentSheet({
merchantDisplayName: 'WellNuo',
paymentIntentClientSecret: data.paymentIntent,
customerId: data.customer,
customerEphemeralKeySecret: data.ephemeralKey,
defaultBillingDetails: {
email: userEmail || '',
},
returnURL: 'wellnuo://stripe-redirect',
applePay: {
merchantCountryCode: 'US',
},
googlePay: {
merchantCountryCode: 'US',
testEnv: true,
},
});
if (initError) {
throw new Error(initError.message);
}
// 3. Present the Payment Sheet
const { error: presentError } = await presentPaymentSheet();
if (presentError) {
if (presentError.code === 'Canceled') {
// User cancelled - do nothing
setIsProcessing(false);
return;
}
throw new Error(presentError.message);
}
// 4. Payment successful!
toast.success('Order Placed!', 'Your WellNuo Starter Kit is on its way.');
onPurchaseSuccess();
} catch (error) {
console.error('Payment error:', error);
toast.error(
'Payment Failed',
error instanceof Error ? error.message : 'Something went wrong. Please try again.'
);
}
setIsProcessing(false);
};
return (
<ScrollView contentContainerStyle={styles.setupScrollContent}>
<View style={styles.setupContainer}>
<View style={styles.setupIconContainer}>
<Ionicons name="hardware-chip-outline" size={48} color={AppColors.primary} />
</View>
<Text style={styles.setupTitle}>Get Started with WellNuo</Text>
<Text style={styles.setupSubtitle}>
To start monitoring {beneficiary.name}'s wellness, you need WellNuo sensors.
</Text>
{/* Primary: Buy Kit Card */}
<View style={styles.buyKitCard}>
<Text style={styles.buyKitName}>{STARTER_KIT.name}</Text>
<Text style={styles.buyKitPrice}>{STARTER_KIT.price}</Text>
<View style={styles.buyKitFeatures}>
{STARTER_KIT.features.map((feature, index) => (
<View key={index} style={styles.buyKitFeatureRow}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.buyKitFeatureText}>{feature}</Text>
</View>
))}
</View>
<TouchableOpacity
style={[styles.buyKitButton, isProcessing && styles.buyKitButtonDisabled]}
onPress={handlePurchase}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.buyKitButtonText}>Buy Now - {STARTER_KIT.price}</Text>
</>
)}
</TouchableOpacity>
{/* Security Badge */}
<View style={styles.securityBadge}>
<Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
<Text style={styles.securityText}>Secure payment by Stripe</Text>
</View>
</View>
{/* Secondary: I already have sensors */}
<TouchableOpacity style={styles.alreadyHaveLink} onPress={onActivate}>
<Text style={styles.alreadyHaveLinkText}>I already have sensors</Text>
<Ionicons name="arrow-forward" size={16} color={AppColors.textSecondary} />
</TouchableOpacity>
</View>
</ScrollView>
);
}
// Equipment status configuration
const equipmentStatusInfo = {
ordered: {
icon: 'cube-outline' as const,
title: 'Kit Ordered',
subtitle: 'Your WellNuo kit is being prepared for shipping',
color: AppColors.info,
bgColor: AppColors.infoLight,
steps: [
{ label: 'Order placed', done: true },
{ label: 'Preparing', done: true },
{ label: 'Shipped', done: false },
{ label: 'Delivered', done: false },
],
},
shipped: {
icon: 'car-outline' as const,
title: 'In Transit',
subtitle: 'Your WellNuo kit is on its way',
color: AppColors.warning,
bgColor: AppColors.warningLight,
steps: [
{ label: 'Order placed', done: true },
{ label: 'Preparing', done: true },
{ label: 'Shipped', done: true },
{ label: 'Delivered', done: false },
],
},
delivered: {
icon: 'checkmark-circle-outline' as const,
title: 'Delivered',
subtitle: 'Your kit has arrived! Time to set it up.',
color: AppColors.success,
bgColor: AppColors.successLight,
steps: [
{ label: 'Order placed', done: true },
{ label: 'Preparing', done: true },
{ label: 'Shipped', done: true },
{ label: 'Delivered', done: true },
],
},
};
// Awaiting Equipment Screen Component
function AwaitingEquipmentScreen({
beneficiary,
onActivate,
onMarkReceived,
}: {
beneficiary: Beneficiary;
onActivate: () => void;
onMarkReceived: () => void;
}) {
const status = beneficiary.equipmentStatus as 'ordered' | 'shipped' | 'delivered';
const info = equipmentStatusInfo[status] || equipmentStatusInfo.ordered;
const isDelivered = status === 'delivered';
return (
<View style={styles.setupContainer}>
<View style={[styles.setupIconContainer, { backgroundColor: info.bgColor }]}>
<Ionicons name={info.icon} size={48} color={info.color} />
</View>
<Text style={styles.setupTitle}>{info.title}</Text>
<Text style={styles.setupSubtitle}>{info.subtitle}</Text>
{/* Progress steps */}
<View style={styles.progressContainer}>
{info.steps.map((step, index) => (
<View key={index} style={styles.progressStep}>
<View style={[
styles.progressDot,
step.done && styles.progressDotDone,
!step.done && styles.progressDotPending,
]}>
{step.done && (
<Ionicons name="checkmark" size={12} color={AppColors.white} />
)}
</View>
<Text style={[
styles.progressLabel,
step.done && styles.progressLabelDone,
]}>
{step.label}
</Text>
{index < info.steps.length - 1 && (
<View style={[
styles.progressLine,
step.done && styles.progressLineDone,
]} />
)}
</View>
))}
</View>
{/* Tracking number if available */}
{beneficiary.trackingNumber && (
<View style={styles.trackingCard}>
<Ionicons name="locate-outline" size={20} color={AppColors.textSecondary} />
<View style={styles.trackingInfo}>
<Text style={styles.trackingLabel}>Tracking Number</Text>
<Text style={styles.trackingNumber}>{beneficiary.trackingNumber}</Text>
</View>
</View>
)}
{/* Actions */}
<View style={styles.awaitingActions}>
{isDelivered ? (
<Button
title="Activate Sensors"
onPress={onActivate}
fullWidth
size="lg"
/>
) : (
<>
<TouchableOpacity style={styles.receivedButton} onPress={onMarkReceived}>
<Ionicons name="checkmark-circle-outline" size={20} color={AppColors.primary} />
<Text style={styles.receivedButtonText}>I received my kit</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
);
}
export default function BeneficiaryDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { setCurrentBeneficiary } = useBeneficiary();
const { user } = useAuth();
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);
// Developer toggle for WebView
const [showWebView, setShowWebView] = useState(false);
const [authToken, setAuthToken] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [isWebViewReady, setIsWebViewReady] = useState(false);
const webViewRef = useRef<WebView>(null);
// Determine setup state
// Flow: No devices → Connect Sensors → Subscription → Dashboard
const setupState = useMemo((): SetupState => {
if (isLoading) return 'loading';
if (!beneficiary) return 'loading';
// Check if has devices - used in multiple places
const hasDevices = beneficiary.hasDevices ||
(beneficiary.devices && beneficiary.devices.length > 0) ||
beneficiary.device_id;
// Check equipment status
const equipmentStatus = beneficiary.equipmentStatus;
// If equipment is ordered/shipped/delivered but NOT yet activated (no devices)
// show awaiting equipment screen
if (equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus)) {
// But if user already has devices (activated), skip to next step
if (!hasDevices) {
return 'awaiting_equipment';
}
// Has devices = already activated, continue to subscription check
}
// No devices and no equipment ordered = show purchase screen
if (!hasDevices) return 'no_devices';
// Check subscription - required after devices connected
const subscription = beneficiary.subscription;
if (!subscription || subscription.status === 'none' || subscription.status === 'expired') {
return 'no_subscription';
}
return 'ready';
}, [beneficiary, isLoading]);
// Dropdown menu state
const [isMenuVisible, setIsMenuVisible] = useState(false);
// Edit modal state
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [editForm, setEditForm] = useState({
name: '',
address: '',
avatar: '' as string | undefined,
});
// Modal animation
const fadeAnim = React.useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: isEditModalVisible ? 1 : 0,
duration: 250,
useNativeDriver: true,
}).start();
}, [isEditModalVisible]);
const loadBeneficiary = useCallback(async (showLoading = true) => {
if (!id) return;
if (showLoading) setIsLoading(true);
setError(null);
try {
// Fetch beneficiary from WellNuo API
const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
if (response.ok && response.data) {
setBeneficiary(response.data);
} 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]);
useEffect(() => {
loadBeneficiary();
}, [loadBeneficiary]);
// Load test credentials for WebView (anandk account has sensor data)
useEffect(() => {
const loadTestCredentials = async () => {
try {
// Always use anandk test account for WebView dashboard
const response = await api.login(TEST_NDK_USER, TEST_NDK_PASSWORD);
if (response.ok && response.data) {
// Note: AuthResponse uses access_token, not token
setAuthToken(response.data.access_token);
setUserName(TEST_NDK_USER);
setUserId(response.data.user_id?.toString() || null);
setIsWebViewReady(true);
console.log('[WebView] Loaded test credentials for anandk, token:', response.data.access_token?.substring(0, 20) + '...');
} else {
console.error('[WebView] Failed to get test token:', response.error);
setIsWebViewReady(false);
}
} catch (err) {
console.error('[WebView] Failed to load test credentials:', err);
setIsWebViewReady(false);
}
};
loadTestCredentials();
}, []);
const handleRefresh = useCallback(() => {
setIsRefreshing(true);
loadBeneficiary(false);
}, [loadBeneficiary]);
const handleActivateSensors = () => {
router.push({
pathname: '/(auth)/activate',
params: { beneficiaryId: id!, lovedOneName: beneficiary?.name },
});
};
const handlePurchaseSuccess = async () => {
// After successful payment, reload beneficiary to show updated status
// The server will handle updating equipment status and subscription
toast.success('Order Placed!', 'Your WellNuo Starter Kit is on its way.');
loadBeneficiary(false);
};
const handleMarkReceived = async () => {
if (!beneficiary || !id) return;
try {
// Update beneficiary equipment status via API
const response = await api.updateWellNuoBeneficiary(parseInt(id, 10), {});
if (response.ok) {
toast.success('Updated', 'Equipment marked as received');
loadBeneficiary(false);
} else {
toast.error('Error', response.error?.message || 'Failed to update status');
}
} catch (err) {
toast.error('Error', 'Failed to update status');
}
};
const handleActivateFromStatus = () => {
router.push({
pathname: '/(auth)/activate',
params: { lovedOneName: beneficiary?.name, beneficiaryId: id },
});
};
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;
}
try {
// Parse name into first and last name
const nameParts = editForm.name.trim().split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
const response = await api.updateWellNuoBeneficiary(parseInt(id, 10), {
firstName,
lastName,
addressStreet: editForm.address.trim() || undefined,
});
if (response.ok) {
setIsEditModalVisible(false);
toast.success('Saved', 'Profile updated successfully');
loadBeneficiary(false);
} else {
toast.error('Error', response.error?.message || 'Failed to save changes.');
}
} catch (err) {
toast.error('Error', 'Failed to save changes.');
}
};
const showComingSoon = (featureName: string) => {
toast.info('Coming Soon', `${featureName} is currently in development.`);
};
// JavaScript to inject token into localStorage for WebView
const injectedJavaScript = authToken
? `
(function() {
try {
var authData = {
username: '${userName || ''}',
token: '${authToken}',
user_id: ${userId || 'null'}
};
localStorage.setItem('auth2', JSON.stringify(authData));
console.log('Auth data injected:', authData.username);
} catch(e) {
console.error('Failed to inject token:', e);
}
})();
true;
`
: '';
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');
}
},
},
]
);
};
if (isLoading) {
return <LoadingSpinner fullScreen message="Loading..." />;
}
if (error || !beneficiary) {
return (
<FullScreenError
message={error || 'Beneficiary not found'}
onRetry={() => loadBeneficiary()}
/>
);
}
// Render based on setup state
const renderContent = () => {
switch (setupState) {
case 'awaiting_equipment':
return (
<AwaitingEquipmentScreen
beneficiary={beneficiary}
onActivate={handleActivateFromStatus}
onMarkReceived={handleMarkReceived}
/>
);
case 'no_devices':
return (
<NoDevicesScreen
beneficiary={beneficiary}
beneficiaryId={id!}
onActivate={handleActivateSensors}
onPurchaseSuccess={handlePurchaseSuccess}
userEmail={user?.email}
userId={user?.user_id}
/>
);
case 'no_subscription':
return (
<SubscriptionPayment
beneficiary={beneficiary}
onSuccess={() => loadBeneficiary(false)}
/>
);
case 'ready':
default:
// Unified dashboard container - same layout for both modes
return (
<View style={styles.dashboardContainer}>
{/* Developer Toggle - same position for both modes */}
<View style={styles.devToggleSection}>
<DevModeToggle value={showWebView} onValueChange={setShowWebView} />
</View>
{/* Content area - WebView or MockDashboard */}
<View style={styles.dashboardContent}>
{showWebView ? (
// WebView mode - uses test anandk account
isWebViewReady && authToken ? (
<WebView
ref={webViewRef}
source={{ uri: getDashboardUrl(FERDINAND_DEPLOYMENT_ID) }}
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 ? 'Loading dashboard...' : 'Connecting to sensors...'}
</Text>
</View>
)
) : (
// Native mode - MockDashboard
<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>
);
}
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.headerButton} onPress={() => {
// Always go to home screen (tabs) to avoid navigating back to add/onboarding flows
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>
<View>
<TouchableOpacity style={styles.headerButton} onPress={() => setIsMenuVisible(!isMenuVisible)}>
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
</TouchableOpacity>
{/* Dropdown Menu */}
{isMenuVisible && (
<View style={styles.dropdownMenu}>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
handleEditPress();
}}
>
<Ionicons name="create-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
router.push(`/(tabs)/beneficiaries/${id}/share`);
}}
>
<Ionicons name="share-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Access</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
router.push(`/(tabs)/beneficiaries/${id}/subscription`);
}}
>
<Ionicons name="diamond-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Subscription</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
router.push(`/(tabs)/beneficiaries/${id}/equipment`);
}}
>
<Ionicons name="hardware-chip-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Equipment</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.dropdownItem, styles.dropdownItemDanger]}
onPress={() => {
setIsMenuVisible(false);
handleDeleteBeneficiary();
}}
>
<Ionicons name="trash-outline" size={20} color={AppColors.error} />
<Text style={[styles.dropdownItemText, styles.dropdownItemTextDanger]}>Remove</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
{/* Backdrop to close menu */}
{isMenuVisible && (
<TouchableOpacity
style={styles.menuBackdrop}
activeOpacity={1}
onPress={() => setIsMenuVisible(false)}
/>
)}
{renderContent()}
{/* Edit Modal */}
<Modal
visible={isEditModalVisible}
animationType="slide"
transparent={true}
onRequestClose={() => setIsEditModalVisible(false)}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<Animated.View style={[styles.modalBackdrop, { opacity: fadeAnim }]}>
<TouchableOpacity
style={StyleSheet.absoluteFill}
activeOpacity={1}
onPress={() => setIsEditModalVisible(false)}
/>
</Animated.View>
<View style={styles.modalContent}>
{/* Modal Header */}
<View style={styles.modalHeader}>
<View style={styles.modalHeaderContent}>
<View style={styles.modalIconContainer}>
<Ionicons name="person" size={22} color={AppColors.primary} />
</View>
<Text style={styles.modalTitle}>Edit Profile</Text>
</View>
<TouchableOpacity style={styles.modalCloseButton} onPress={() => setIsEditModalVisible(false)}>
<Ionicons name="close" size={22} color={AppColors.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.modalForm} showsVerticalScrollIndicator={false}>
{/* Avatar Section */}
<View style={styles.editAvatarSection}>
<TouchableOpacity style={styles.editAvatarContainer} onPress={handlePickAvatar}>
{editForm.avatar ? (
<Image source={{ uri: editForm.avatar }} style={styles.editAvatarImage} />
) : (
<Text style={styles.editAvatarText}>
{editForm.name ? editForm.name.charAt(0).toUpperCase() : '+'}
</Text>
)}
<View style={styles.editAvatarBadge}>
<Ionicons name="camera" size={14} color={AppColors.white} />
</View>
</TouchableOpacity>
<Text style={styles.editAvatarHint}>Tap to change photo</Text>
</View>
{/* Form Fields */}
<View style={styles.formSection}>
<Text style={styles.inputLabel}>Name</Text>
<View style={styles.inputContainer}>
<Ionicons name="person-outline" size={20} color={AppColors.textMuted} />
<TextInput
style={styles.textInput}
value={editForm.name}
onChangeText={(text) => setEditForm({ ...editForm, name: text })}
placeholder="Enter name"
placeholderTextColor={AppColors.textMuted}
/>
</View>
</View>
<View style={styles.formSection}>
<Text style={styles.inputLabel}>Address</Text>
<View style={styles.inputContainer}>
<Ionicons name="location-outline" size={20} color={AppColors.textMuted} />
<TextInput
style={styles.textInput}
value={editForm.address}
onChangeText={(text) => setEditForm({ ...editForm, address: text })}
placeholder="Enter address"
placeholderTextColor={AppColors.textMuted}
/>
</View>
</View>
{/* Info Box */}
<View style={styles.infoBox}>
<Ionicons name="information-circle" size={20} color={AppColors.info} />
<Text style={styles.infoBoxText}>
Wellness data is collected automatically from connected sensors.
</Text>
</View>
</ScrollView>
{/* Modal Footer */}
<View style={styles.modalFooter}>
<TouchableOpacity style={styles.cancelButton} onPress={() => setIsEditModalVisible(false)}>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.saveButton} onPress={handleSaveEdit}>
<Ionicons name="checkmark" size={20} color={AppColors.white} />
<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.lg,
paddingVertical: Spacing.md,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
zIndex: 1001,
},
headerButton: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: Spacing.md,
gap: Spacing.sm,
},
headerAvatarWrapper: {
position: 'relative',
},
headerAvatar: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
},
headerAvatarImage: {
width: 36,
height: 36,
borderRadius: 18,
},
headerAvatarText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.bold,
color: AppColors.white,
},
headerStatusDot: {
position: 'absolute',
bottom: 0,
right: 0,
width: 12,
height: 12,
borderRadius: 6,
borderWidth: 2,
borderColor: AppColors.background,
},
// Unified Dashboard Container (same for WebView and MockDashboard)
dashboardContainer: {
flex: 1,
backgroundColor: AppColors.background,
},
devToggleSection: {
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.md,
paddingBottom: Spacing.sm,
backgroundColor: AppColors.background,
},
dashboardContent: {
flex: 1,
},
// WebView
webView: {
flex: 1,
},
webViewLoading: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: AppColors.background,
},
webViewLoadingText: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
// Native ScrollView (for MockDashboard)
nativeScrollView: {
flex: 1,
},
nativeScrollContent: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
// Setup Screens
setupScrollContent: {
flexGrow: 1,
justifyContent: 'center',
},
setupContainer: {
padding: Spacing.xl,
alignItems: 'center',
},
setupIconContainer: {
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.lg,
},
setupTitle: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
textAlign: 'center',
marginBottom: Spacing.sm,
},
setupSubtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 24,
marginBottom: Spacing.xl,
paddingHorizontal: Spacing.md,
},
setupOptions: {
width: '100%',
gap: Spacing.md,
},
setupOptionCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
...Shadows.sm,
},
setupOptionIcon: {
width: 56,
height: 56,
borderRadius: BorderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
setupOptionTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
setupOptionText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
lineHeight: 20,
},
setupOptionArrow: {
position: 'absolute',
top: Spacing.lg,
right: Spacing.lg,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
},
// Buy Kit Card Styles
buyKitCard: {
width: '100%',
backgroundColor: AppColors.white,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
borderWidth: 2,
borderColor: AppColors.primary,
alignItems: 'center',
...Shadows.md,
},
buyKitName: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.sm,
},
buyKitPrice: {
fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
marginBottom: Spacing.lg,
},
buyKitFeatures: {
width: '100%',
marginBottom: Spacing.lg,
gap: Spacing.sm,
},
buyKitFeatureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
buyKitFeatureText: {
fontSize: FontSizes.sm,
color: AppColors.textPrimary,
},
buyKitButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.xl,
borderRadius: BorderRadius.lg,
width: '100%',
},
buyKitButtonText: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
buyKitButtonDisabled: {
opacity: 0.7,
},
securityBadge: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.xs,
marginTop: Spacing.md,
},
securityText: {
fontSize: FontSizes.xs,
color: AppColors.success,
},
alreadyHaveLink: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.xs,
marginTop: Spacing.xl,
paddingVertical: Spacing.md,
},
alreadyHaveLinkText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
// Equipment Progress Styles
progressContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-start',
marginBottom: Spacing.xl,
width: '100%',
paddingHorizontal: Spacing.md,
},
progressStep: {
alignItems: 'center',
flex: 1,
position: 'relative',
},
progressDot: {
width: 24,
height: 24,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.xs,
},
progressDotDone: {
backgroundColor: AppColors.success,
},
progressDotPending: {
backgroundColor: AppColors.border,
},
progressLabel: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
textAlign: 'center',
},
progressLabelDone: {
color: AppColors.textPrimary,
fontWeight: FontWeights.medium,
},
progressLine: {
position: 'absolute',
top: 12,
left: '50%',
right: '-50%',
height: 2,
backgroundColor: AppColors.border,
zIndex: -1,
},
progressLineDone: {
backgroundColor: AppColors.success,
},
trackingCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.lg,
width: '100%',
gap: Spacing.md,
...Shadows.sm,
},
trackingInfo: {
flex: 1,
},
trackingLabel: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
trackingNumber: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
awaitingActions: {
width: '100%',
},
receivedButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
borderWidth: 1,
borderColor: AppColors.primary,
gap: Spacing.sm,
},
receivedButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.primary,
},
// Subscription Price Card
subscriptionPriceCard: {
width: '100%',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.xl,
...Shadows.sm,
},
subscriptionPriceHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.md,
},
subscriptionPriceLabel: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
subscriptionPriceBadge: {
flexDirection: 'row',
alignItems: 'baseline',
},
subscriptionPriceAmount: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
subscriptionPriceUnit: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginLeft: 2,
},
subscriptionPriceFeatures: {
gap: Spacing.sm,
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
featureRowText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
// Profile Card
profileCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
alignItems: 'center',
marginBottom: Spacing.lg,
...Shadows.sm,
},
avatarWrapper: {
position: 'relative',
marginBottom: Spacing.md,
},
avatar: {
width: AvatarSizes.xl,
height: AvatarSizes.xl,
borderRadius: AvatarSizes.xl / 2,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
},
avatarImage: {
width: AvatarSizes.xl,
height: AvatarSizes.xl,
borderRadius: AvatarSizes.xl / 2,
},
avatarText: {
fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold,
color: AppColors.white,
},
statusDot: {
position: 'absolute',
bottom: 4,
right: 4,
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 3,
borderColor: AppColors.surface,
},
beneficiaryName: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
locationRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
marginBottom: Spacing.sm,
},
locationText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surfaceSecondary,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.xs,
borderRadius: BorderRadius.full,
gap: Spacing.xs,
},
statusIndicator: {
width: 8,
height: 8,
borderRadius: 4,
},
statusText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary,
},
lastActivity: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: Spacing.sm,
},
// Section
sectionTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.md,
},
// Quick Actions Row
actionsRow: {
flexDirection: 'row',
gap: Spacing.md,
marginBottom: Spacing.lg,
},
actionButton: {
flex: 1,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
alignItems: 'center',
...Shadows.xs,
},
actionButtonIcon: {
width: 48,
height: 48,
borderRadius: BorderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.sm,
},
actionButtonText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
},
// Subscription Card
subscriptionCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.lg,
...Shadows.sm,
},
subscriptionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
subscriptionBadgeContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
subscriptionIconBg: {
width: 44,
height: 44,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.accentLight,
justifyContent: 'center',
alignItems: 'center',
},
subscriptionPlan: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
subscriptionStatus: {
fontSize: FontSizes.sm,
color: AppColors.success,
fontWeight: FontWeights.medium,
},
priceBadge: {
flexDirection: 'row',
alignItems: 'baseline',
},
priceText: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
priceUnit: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginLeft: 2,
},
subscriptionDivider: {
height: 1,
backgroundColor: AppColors.borderLight,
marginVertical: Spacing.md,
},
subscriptionFeatures: {
gap: Spacing.sm,
marginBottom: Spacing.md,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
featureText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
manageButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: Spacing.md,
backgroundColor: AppColors.primarySubtle,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
},
manageButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
// Settings Card
settingsCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
marginBottom: Spacing.lg,
...Shadows.xs,
},
settingsRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: Spacing.md,
},
settingsRowLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
settingsIcon: {
width: 36,
height: 36,
borderRadius: BorderRadius.md,
justifyContent: 'center',
alignItems: 'center',
},
settingsRowText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
},
settingsDivider: {
height: 1,
backgroundColor: AppColors.borderLight,
marginLeft: 60,
},
// Danger Zone
dangerTitle: {
color: AppColors.error,
},
dangerButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.errorLight,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.sm,
marginBottom: Spacing.lg,
},
dangerButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.error,
},
// Modal
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: AppColors.overlay,
},
modalContent: {
backgroundColor: AppColors.background,
borderTopLeftRadius: BorderRadius['2xl'],
borderTopRightRadius: BorderRadius['2xl'],
maxHeight: '85%',
...Shadows.xl,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.lg,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
modalHeaderContent: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
modalIconContainer: {
width: 40,
height: 40,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
},
modalTitle: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
modalCloseButton: {
width: 36,
height: 36,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
},
modalForm: {
padding: Spacing.lg,
},
// Edit Avatar
editAvatarSection: {
alignItems: 'center',
marginBottom: Spacing.lg,
},
editAvatarContainer: {
width: AvatarSizes.lg,
height: AvatarSizes.lg,
borderRadius: AvatarSizes.lg / 2,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
},
editAvatarImage: {
width: AvatarSizes.lg,
height: AvatarSizes.lg,
borderRadius: AvatarSizes.lg / 2,
},
editAvatarText: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.white,
},
editAvatarBadge: {
position: 'absolute',
bottom: 0,
right: 0,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 3,
borderColor: AppColors.background,
},
editAvatarHint: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginTop: Spacing.sm,
},
// Form
formSection: {
marginBottom: Spacing.md,
},
inputLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary,
marginBottom: Spacing.xs,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surfaceSecondary,
borderRadius: BorderRadius.lg,
paddingHorizontal: Spacing.md,
borderWidth: 1.5,
borderColor: AppColors.borderLight,
gap: Spacing.sm,
},
textInput: {
flex: 1,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
paddingVertical: Spacing.md,
},
infoBox: {
flexDirection: 'row',
backgroundColor: AppColors.infoLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
gap: Spacing.sm,
marginTop: Spacing.md,
},
infoBoxText: {
flex: 1,
fontSize: FontSizes.sm,
color: AppColors.info,
lineHeight: 20,
},
modalFooter: {
flexDirection: 'row',
padding: Spacing.lg,
gap: Spacing.md,
borderTopWidth: 1,
borderTopColor: AppColors.border,
},
cancelButton: {
flex: 1,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.surfaceSecondary,
alignItems: 'center',
justifyContent: 'center',
},
cancelButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary,
},
saveButton: {
flex: 2,
flexDirection: 'row',
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primary,
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.xs,
...Shadows.primary,
},
saveButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// Dropdown Menu
dropdownMenu: {
position: 'absolute',
top: 44,
right: 0,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
minWidth: 160,
...Shadows.lg,
zIndex: 1000,
},
dropdownItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.lg,
gap: Spacing.md,
},
dropdownItemText: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
dropdownItemDanger: {
borderTopWidth: 1,
borderTopColor: AppColors.borderLight,
},
dropdownItemTextDanger: {
color: AppColors.error,
},
menuBackdrop: {
...StyleSheet.absoluteFillObject,
zIndex: 999,
},
});