Update subscription, equipment screens and auth flow

This commit is contained in:
Sergei 2025-12-30 21:14:24 -08:00
parent 2545aec485
commit b869e9e3ab
16 changed files with 2798 additions and 487 deletions

View File

@ -12,13 +12,16 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export default function LoginScreen() { export default function LoginScreen() {
const { checkEmail, requestOtp, isLoading, error, clearError } = useAuth(); const { checkEmail, requestOtp, isLoading, error, clearError } = useAuth();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [partnerCode, setPartnerCode] = useState(''); const [partnerCode, setPartnerCode] = useState('');
const [showPartnerCode, setShowPartnerCode] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
// Clear errors on mount // Clear errors on mount
@ -140,22 +143,33 @@ export default function LoginScreen() {
returnKeyType="next" returnKeyType="next"
/> />
<Input {/* Partner Code Toggle */}
label="Partner Code (optional)" {!showPartnerCode ? (
placeholder="6-digit code from a friend" <TouchableOpacity
leftIcon="people-outline" style={styles.partnerCodeToggle}
value={partnerCode} onPress={() => setShowPartnerCode(true)}
onChangeText={(text) => { >
// Only allow digits, max 6 <Ionicons name="gift-outline" size={18} color={AppColors.primary} />
const digits = text.replace(/\D/g, '').slice(0, 6); <Text style={styles.partnerCodeToggleText}>I have a partner code</Text>
setPartnerCode(digits); </TouchableOpacity>
}} ) : (
keyboardType="number-pad" <Input
maxLength={6} label="Partner Code"
editable={!isLoading} placeholder="Enter 5-digit code"
onSubmitEditing={handleContinue} leftIcon="gift-outline"
returnKeyType="done" value={partnerCode}
/> onChangeText={(text) => {
// Only allow digits, max 5
const code = text.replace(/\D/g, '').slice(0, 5);
setPartnerCode(code);
}}
keyboardType="number-pad"
maxLength={5}
editable={!isLoading}
onSubmitEditing={handleContinue}
returnKeyType="done"
/>
)}
<Button <Button
title="Continue" title="Continue"
@ -236,4 +250,17 @@ const styles = StyleSheet.create({
color: AppColors.textMuted, color: AppColors.textMuted,
marginTop: 'auto', marginTop: 'auto',
}, },
partnerCodeToggle: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: Spacing.sm,
marginTop: Spacing.xs,
gap: Spacing.xs,
},
partnerCodeToggleText: {
fontSize: FontSizes.sm,
color: AppColors.primary,
fontWeight: '500',
},
}); });

View File

@ -1,14 +1,19 @@
import React, { useState, useRef, useEffect, useMemo } from 'react'; import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity, Modal, TextInput, Image, ScrollView, KeyboardAvoidingView, Platform, Alert, Animated } from 'react-native';
import { WebView } from 'react-native-webview'; import { WebView } from 'react-native-webview';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, router } from 'expo-router'; import { useLocalSearchParams, router } from 'expo-router';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import * as ImagePicker from 'expo-image-picker';
import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import { api } from '@/services/api';
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows, AvatarSizes } from '@/constants/theme';
import { FullScreenError } from '@/components/ui/ErrorMessage'; import { FullScreenError } from '@/components/ui/ErrorMessage';
import { useToast } from '@/components/ui/Toast';
import MockDashboard from '@/components/MockDashboard'; import MockDashboard from '@/components/MockDashboard';
import { SubscriptionPayment } from '@/components/SubscriptionPayment';
import type { Beneficiary } from '@/types';
// Dashboard URL with beneficiary ID (deployment_id) // Dashboard URL with beneficiary ID (deployment_id)
const getDashboardUrl = (deploymentId: string) => const getDashboardUrl = (deploymentId: string) =>
@ -23,7 +28,8 @@ const isLocalBeneficiary = (id: string | number): boolean => {
export default function BeneficiaryDashboardScreen() { export default function BeneficiaryDashboardScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary, setCurrentBeneficiary } = useBeneficiary(); const { currentBeneficiary, setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary } = useBeneficiary();
const toast = useToast();
const webViewRef = useRef<WebView>(null); const webViewRef = useRef<WebView>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -32,10 +38,128 @@ export default function BeneficiaryDashboardScreen() {
const [userName, setUserName] = useState<string | null>(null); const [userName, setUserName] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [isTokenLoaded, setIsTokenLoaded] = useState(false); const [isTokenLoaded, setIsTokenLoaded] = useState(false);
const [isMenuVisible, setIsMenuVisible] = useState(false);
// Edit modal state
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [editForm, setEditForm] = useState({
name: '',
address: '',
avatar: '' as string | undefined,
});
const fadeAnim = useRef(new Animated.Value(0)).current;
// Beneficiary data for subscription check
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isBeneficiaryLoading, setIsBeneficiaryLoading] = useState(true);
// Check if this is a local (mock) beneficiary // Check if this is a local (mock) beneficiary
const isLocal = useMemo(() => id ? isLocalBeneficiary(id) : false, [id]); const isLocal = useMemo(() => id ? isLocalBeneficiary(id) : false, [id]);
// Check subscription status
const hasActiveSubscription = useMemo(() => {
if (!beneficiary) return false;
const subscription = beneficiary.subscription;
return subscription && subscription.status === 'active';
}, [beneficiary]);
// Load beneficiary data to check subscription
const loadBeneficiary = useCallback(async () => {
if (!id) return;
setIsBeneficiaryLoading(true);
try {
if (isLocal) {
const localBeneficiary = localBeneficiaries.find(
(b) => b.id === parseInt(id, 10)
);
if (localBeneficiary) {
setBeneficiary(localBeneficiary);
}
} else {
const response = await api.getBeneficiary(parseInt(id, 10));
if (response.ok && response.data) {
setBeneficiary(response.data);
}
}
} catch (err) {
console.error('Failed to load beneficiary:', err);
} finally {
setIsBeneficiaryLoading(false);
}
}, [id, isLocal, localBeneficiaries]);
useEffect(() => {
loadBeneficiary();
}, [loadBeneficiary]);
// Edit modal animation
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: isEditModalVisible ? 1 : 0,
duration: 250,
useNativeDriver: true,
}).start();
}, [isEditModalVisible]);
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') {
Alert.alert('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()) {
toast.error('Error', 'Name is required');
return;
}
if (isLocal && id) {
const updated = await updateLocalBeneficiary(parseInt(id, 10), {
name: editForm.name.trim(),
address: editForm.address.trim() || undefined,
avatar: editForm.avatar,
});
if (updated) {
setBeneficiary(updated);
setCurrentBeneficiary(updated);
setIsEditModalVisible(false);
toast.success('Profile Updated', 'Changes saved successfully');
} else {
toast.error('Error', 'Failed to save changes.');
}
} else {
// For API beneficiaries - would call backend here
toast.info('Coming Soon', 'Editing requires backend API.');
setIsEditModalVisible(false);
}
};
// Build dashboard URL with beneficiary ID // Build dashboard URL with beneficiary ID
const dashboardUrl = id ? getDashboardUrl(id) : 'https://react.eluxnetworks.net/dashboard'; const dashboardUrl = id ? getDashboardUrl(id) : 'https://react.eluxnetworks.net/dashboard';
@ -108,8 +232,8 @@ export default function BeneficiaryDashboardScreen() {
router.back(); router.back();
}; };
// Wait for token to load before showing WebView (skip for local beneficiaries) // Wait for beneficiary data and token to load
if (!isTokenLoaded && !isLocal) { if (isBeneficiaryLoading || (!isTokenLoaded && !isLocal)) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}> <View style={styles.header}>
@ -127,6 +251,26 @@ export default function BeneficiaryDashboardScreen() {
); );
} }
// NO SUBSCRIPTION - Show payment screen with Stripe integration
if (!hasActiveSubscription && beneficiary) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
<View style={styles.placeholder} />
</View>
<SubscriptionPayment
beneficiary={beneficiary}
onSuccess={() => loadBeneficiary()}
/>
</SafeAreaView>
);
}
if (error) { if (error) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
@ -178,9 +322,61 @@ export default function BeneficiaryDashboardScreen() {
<Ionicons name="refresh" size={22} color={AppColors.primary} /> <Ionicons name="refresh" size={22} color={AppColors.primary} />
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* Menu button */}
<TouchableOpacity style={styles.menuButton} 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="people-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}/equipment`);
}}
>
<Ionicons name="hardware-chip-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Equipment</Text>
</TouchableOpacity>
</View>
)}
</View> </View>
</View> </View>
{/* Backdrop to close menu */}
{isMenuVisible && (
<TouchableOpacity
style={styles.menuBackdrop}
activeOpacity={1}
onPress={() => setIsMenuVisible(false)}
/>
)}
{/* Dashboard Content - Mock for local, WebView for real */} {/* Dashboard Content - Mock for local, WebView for real */}
{isLocal ? ( {isLocal ? (
<MockDashboard beneficiaryName={beneficiaryName} /> <MockDashboard beneficiaryName={beneficiaryName} />
@ -220,45 +416,98 @@ export default function BeneficiaryDashboardScreen() {
</View> </View>
)} )}
{/* Bottom Quick Actions */} {/* Edit Modal */}
<View style={styles.bottomBar}> <Modal
<TouchableOpacity visible={isEditModalVisible}
style={styles.quickAction} transparent
onPress={() => { animationType="none"
if (currentBeneficiary) { onRequestClose={() => setIsEditModalVisible(false)}
setCurrentBeneficiary(currentBeneficiary); >
} <KeyboardAvoidingView
router.push('/(tabs)/chat'); behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
}} style={styles.modalOverlay}
> >
<Ionicons name="chatbubble-ellipses" size={22} color={AppColors.primary} /> <Animated.View style={[styles.modalOverlay, { opacity: fadeAnim }]}>
<Text style={styles.quickActionText}>Chat</Text> <TouchableOpacity
</TouchableOpacity> style={styles.modalBackdrop}
activeOpacity={1}
onPress={() => setIsEditModalVisible(false)}
/>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Edit Profile</Text>
<TouchableOpacity
style={styles.modalCloseButton}
onPress={() => setIsEditModalVisible(false)}
>
<Ionicons name="close" size={24} color={AppColors.textSecondary} />
</TouchableOpacity>
</View>
<TouchableOpacity <ScrollView showsVerticalScrollIndicator={false}>
style={styles.quickAction} {/* Avatar Picker */}
onPress={() => router.push(`./edit`)} <TouchableOpacity style={styles.avatarPicker} onPress={handlePickAvatar}>
> {editForm.avatar ? (
<Ionicons name="create-outline" size={22} color={AppColors.primary} /> <Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
<Text style={styles.quickActionText}>Edit</Text> ) : (
</TouchableOpacity> <View style={styles.avatarPickerPlaceholder}>
<Text style={styles.avatarPickerLetter}>
{editForm.name.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
<View style={styles.avatarPickerBadge}>
<Ionicons name="camera" size={16} color={AppColors.white} />
</View>
</TouchableOpacity>
<TouchableOpacity {/* Name Field */}
style={styles.quickAction} <View style={styles.inputGroup}>
onPress={() => router.push(`/(tabs)/profile/subscription`)} <Text style={styles.inputLabel}>Name *</Text>
> <TextInput
<Ionicons name="card-outline" size={22} color={AppColors.primary} /> style={styles.textInput}
<Text style={styles.quickActionText}>Subscribe</Text> value={editForm.name}
</TouchableOpacity> onChangeText={(text) => setEditForm(prev => ({ ...prev, name: text }))}
placeholder="Enter name"
placeholderTextColor={AppColors.textMuted}
/>
</View>
{/* Address Field */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Address</Text>
<TextInput
style={[styles.textInput, styles.textInputMultiline]}
value={editForm.address}
onChangeText={(text) => setEditForm(prev => ({ ...prev, address: text }))}
placeholder="Enter address (optional)"
placeholderTextColor={AppColors.textMuted}
multiline
numberOfLines={2}
/>
</View>
</ScrollView>
{/* Action Buttons */}
<View style={styles.modalActions}>
<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>
</Animated.View>
</KeyboardAvoidingView>
</Modal>
<TouchableOpacity
style={styles.quickAction}
onPress={() => router.push(`./share`)}
>
<Ionicons name="share-outline" size={22} color={AppColors.primary} />
<Text style={styles.quickActionText}>Share</Text>
</TouchableOpacity>
</View>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -276,6 +525,7 @@ const styles = StyleSheet.create({
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: AppColors.border, borderBottomColor: AppColors.border,
zIndex: 1001,
}, },
backButton: { backButton: {
padding: Spacing.xs, padding: Spacing.xs,
@ -312,14 +562,53 @@ const styles = StyleSheet.create({
headerActions: { headerActions: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
position: 'relative',
}, },
actionButton: { actionButton: {
padding: Spacing.xs, padding: Spacing.xs,
marginLeft: Spacing.xs, marginLeft: Spacing.xs,
}, },
menuButton: {
padding: Spacing.xs,
marginLeft: Spacing.sm,
},
placeholder: { placeholder: {
width: 32, width: 32,
}, },
// Dropdown Menu
dropdownMenu: {
position: 'absolute',
top: 40,
right: 0,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
minWidth: 160,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
zIndex: 1000,
},
dropdownItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.lg,
gap: Spacing.md,
},
dropdownItemText: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
menuBackdrop: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999,
},
webViewContainer: { webViewContainer: {
flex: 1, flex: 1,
}, },
@ -351,22 +640,202 @@ const styles = StyleSheet.create({
fontSize: FontSizes.base, fontSize: FontSizes.base,
color: AppColors.textSecondary, color: AppColors.textSecondary,
}, },
bottomBar: { // No Subscription Styles
flexDirection: 'row', noSubscriptionContainer: {
justifyContent: 'space-around', flex: 1,
paddingVertical: Spacing.sm, padding: Spacing.xl,
paddingHorizontal: Spacing.lg,
backgroundColor: AppColors.background,
borderTopWidth: 1,
borderTopColor: AppColors.border,
},
quickAction: {
alignItems: 'center', alignItems: 'center',
padding: Spacing.sm, justifyContent: 'center',
}, },
quickActionText: { noSubIconContainer: {
fontSize: FontSizes.xs, width: 100,
height: 100,
borderRadius: 50,
backgroundColor: AppColors.accentLight,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.lg,
},
noSubTitle: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
textAlign: 'center',
marginBottom: Spacing.sm,
},
noSubSubtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 24,
marginBottom: Spacing.xl,
paddingHorizontal: Spacing.md,
},
noSubPriceCard: {
width: '100%',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.xl,
...Shadows.sm,
},
noSubPriceHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.md,
},
noSubPlanName: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
noSubPlanDesc: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginTop: 2,
},
noSubPriceBadge: {
flexDirection: 'row',
alignItems: 'baseline',
},
noSubPriceAmount: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary, color: AppColors.primary,
marginTop: Spacing.xs, },
noSubPriceUnit: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginLeft: 2,
},
noSubFeatures: {
gap: Spacing.sm,
},
noSubFeatureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
noSubFeatureText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
// Edit Modal Styles
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
modalContent: {
backgroundColor: AppColors.surface,
borderTopLeftRadius: BorderRadius['2xl'],
borderTopRightRadius: BorderRadius['2xl'],
padding: Spacing.lg,
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.lg,
},
modalTitle: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
modalCloseButton: {
padding: Spacing.xs,
},
avatarPicker: {
alignSelf: 'center',
marginBottom: Spacing.xl,
position: 'relative',
},
avatarPickerImage: {
width: AvatarSizes.xl,
height: AvatarSizes.xl,
borderRadius: AvatarSizes.xl / 2,
},
avatarPickerPlaceholder: {
width: AvatarSizes.xl,
height: AvatarSizes.xl,
borderRadius: AvatarSizes.xl / 2,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
},
avatarPickerLetter: {
fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
avatarPickerBadge: {
position: 'absolute',
bottom: 0,
right: 0,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 3,
borderColor: AppColors.surface,
},
inputGroup: {
marginBottom: Spacing.lg,
},
inputLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary,
marginBottom: Spacing.sm,
},
textInput: {
backgroundColor: AppColors.background,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
borderWidth: 1,
borderColor: AppColors.border,
},
textInputMultiline: {
minHeight: 80,
textAlignVertical: 'top',
},
modalActions: {
flexDirection: 'row',
gap: Spacing.md,
marginTop: Spacing.lg,
},
cancelButton: {
flex: 1,
backgroundColor: AppColors.surfaceSecondary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
alignItems: 'center',
},
cancelButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
},
saveButton: {
flex: 1,
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
alignItems: 'center',
},
saveButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
}, },
}); });

View File

@ -0,0 +1,609 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
RefreshControl,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { api } from '@/services/api';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
interface Device {
id: string;
name: string;
type: 'motion' | 'door' | 'temperature' | 'hub';
status: 'online' | 'offline';
lastSeen?: string;
room?: string;
}
const deviceTypeConfig = {
motion: {
icon: 'body-outline' as const,
label: 'Motion Sensor',
color: AppColors.primary,
bgColor: AppColors.primaryLighter,
},
door: {
icon: 'enter-outline' as const,
label: 'Door Sensor',
color: AppColors.info,
bgColor: AppColors.infoLight,
},
temperature: {
icon: 'thermometer-outline' as const,
label: 'Temperature',
color: AppColors.warning,
bgColor: AppColors.warningLight,
},
hub: {
icon: 'git-network-outline' as const,
label: 'Hub',
color: AppColors.accent,
bgColor: AppColors.accentLight,
},
};
export default function EquipmentScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary } = useBeneficiary();
const [devices, setDevices] = useState<Device[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isDetaching, setIsDetaching] = useState<string | null>(null);
const beneficiaryName = currentBeneficiary?.name || 'this person';
useEffect(() => {
loadDevices();
}, [id]);
const loadDevices = async () => {
if (!id) return;
try {
// For now, mock data - replace with actual API call
// const response = await api.getDevices(id);
// Mock devices for demonstration
const mockDevices: Device[] = [
{
id: '1',
name: 'Living Room Motion',
type: 'motion',
status: 'online',
lastSeen: '2 min ago',
room: 'Living Room',
},
{
id: '2',
name: 'Front Door',
type: 'door',
status: 'online',
lastSeen: '5 min ago',
room: 'Entrance',
},
{
id: '3',
name: 'Bedroom Motion',
type: 'motion',
status: 'offline',
lastSeen: '2 hours ago',
room: 'Bedroom',
},
{
id: '4',
name: 'Temperature Monitor',
type: 'temperature',
status: 'online',
lastSeen: '1 min ago',
room: 'Kitchen',
},
{
id: '5',
name: 'WellNuo Hub',
type: 'hub',
status: 'online',
lastSeen: 'Just now',
},
];
setDevices(mockDevices);
} catch (error) {
console.error('Failed to load devices:', error);
Alert.alert('Error', 'Failed to load devices');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
};
const handleRefresh = useCallback(() => {
setIsRefreshing(true);
loadDevices();
}, [id]);
const handleDetachDevice = (device: Device) => {
Alert.alert(
'Detach Device',
`Are you sure you want to detach "${device.name}" from ${beneficiaryName}?\n\nThe device will become available for use with another person.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Detach',
style: 'destructive',
onPress: async () => {
setIsDetaching(device.id);
try {
// API call to detach device
// await api.detachDevice(id, device.id);
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
// Remove from local state
setDevices(prev => prev.filter(d => d.id !== device.id));
Alert.alert('Success', `${device.name} has been detached.`);
} catch (error) {
Alert.alert('Error', 'Failed to detach device. Please try again.');
} finally {
setIsDetaching(null);
}
},
},
]
);
};
const handleDetachAll = () => {
if (devices.length === 0) {
Alert.alert('No Devices', 'There are no devices to detach.');
return;
}
Alert.alert(
'Detach All Devices',
`Are you sure you want to detach all ${devices.length} devices from ${beneficiaryName}?\n\nThis action cannot be undone.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Detach All',
style: 'destructive',
onPress: async () => {
setIsLoading(true);
try {
// API call to detach all devices
// await api.detachAllDevices(id);
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1500));
setDevices([]);
Alert.alert('Success', 'All devices have been detached.');
} catch (error) {
Alert.alert('Error', 'Failed to detach devices. Please try again.');
} finally {
setIsLoading(false);
}
},
},
]
);
};
const handleAddDevice = () => {
router.push({
pathname: '/(auth)/activate',
params: { lovedOneName: beneficiaryName, beneficiaryId: id },
});
};
if (isLoading) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Equipment</Text>
<View style={styles.placeholder} />
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Loading devices...</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Equipment</Text>
<TouchableOpacity style={styles.addButton} onPress={handleAddDevice}>
<Ionicons name="add" size={24} color={AppColors.primary} />
</TouchableOpacity>
</View>
<ScrollView
style={styles.content}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} />
}
>
{/* Summary Card */}
<View style={styles.summaryCard}>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryValue}>{devices.length}</Text>
<Text style={styles.summaryLabel}>Total Devices</Text>
</View>
<View style={styles.summaryDivider} />
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: AppColors.success }]}>
{devices.filter(d => d.status === 'online').length}
</Text>
<Text style={styles.summaryLabel}>Online</Text>
</View>
<View style={styles.summaryDivider} />
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: AppColors.error }]}>
{devices.filter(d => d.status === 'offline').length}
</Text>
<Text style={styles.summaryLabel}>Offline</Text>
</View>
</View>
</View>
{/* Devices List */}
{devices.length === 0 ? (
<View style={styles.emptyState}>
<View style={styles.emptyIconContainer}>
<Ionicons name="hardware-chip-outline" size={48} color={AppColors.textMuted} />
</View>
<Text style={styles.emptyTitle}>No Devices Connected</Text>
<Text style={styles.emptyText}>
Add sensors to start monitoring {beneficiaryName}'s wellness.
</Text>
<TouchableOpacity style={styles.addDeviceButton} onPress={handleAddDevice}>
<Ionicons name="add" size={20} color={AppColors.white} />
<Text style={styles.addDeviceButtonText}>Add Device</Text>
</TouchableOpacity>
</View>
) : (
<>
<Text style={styles.sectionTitle}>Connected Devices</Text>
<View style={styles.devicesList}>
{devices.map((device) => {
const config = deviceTypeConfig[device.type];
const isDetachingThis = isDetaching === device.id;
return (
<View key={device.id} style={styles.deviceCard}>
<View style={styles.deviceInfo}>
<View style={[styles.deviceIcon, { backgroundColor: config.bgColor }]}>
<Ionicons name={config.icon} size={22} color={config.color} />
</View>
<View style={styles.deviceDetails}>
<Text style={styles.deviceName}>{device.name}</Text>
<View style={styles.deviceMeta}>
<View style={[
styles.statusDot,
{ backgroundColor: device.status === 'online' ? AppColors.success : AppColors.error }
]} />
<Text style={styles.deviceStatus}>
{device.status === 'online' ? 'Online' : 'Offline'}
</Text>
{device.room && (
<>
<Text style={styles.deviceMetaSeparator}></Text>
<Text style={styles.deviceRoom}>{device.room}</Text>
</>
)}
</View>
</View>
</View>
<TouchableOpacity
style={styles.detachButton}
onPress={() => handleDetachDevice(device)}
disabled={isDetachingThis}
>
{isDetachingThis ? (
<ActivityIndicator size="small" color={AppColors.error} />
) : (
<Ionicons name="unlink-outline" size={20} color={AppColors.error} />
)}
</TouchableOpacity>
</View>
);
})}
</View>
{/* Detach All Button */}
{devices.length > 1 && (
<TouchableOpacity style={styles.detachAllButton} onPress={handleDetachAll}>
<Ionicons name="trash-outline" size={20} color={AppColors.error} />
<Text style={styles.detachAllText}>Detach All Devices</Text>
</TouchableOpacity>
)}
</>
)}
{/* Info Section */}
<View style={styles.infoCard}>
<View style={styles.infoHeader}>
<Ionicons name="information-circle" size={20} color={AppColors.info} />
<Text style={styles.infoTitle}>About Equipment</Text>
</View>
<Text style={styles.infoText}>
Detaching a device will remove it from {beneficiaryName}'s monitoring setup.
You can then attach it to another person or re-attach it later using the activation code.
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 32,
},
addButton: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
},
content: {
flex: 1,
},
scrollContent: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.md,
},
loadingText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
// Summary Card
summaryCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.lg,
...Shadows.sm,
},
summaryRow: {
flexDirection: 'row',
alignItems: 'center',
},
summaryItem: {
flex: 1,
alignItems: 'center',
},
summaryValue: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
summaryLabel: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: 2,
},
summaryDivider: {
width: 1,
height: 32,
backgroundColor: AppColors.border,
},
// Section Title
sectionTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
marginBottom: Spacing.md,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// Devices List
devicesList: {
gap: Spacing.md,
},
deviceCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
...Shadows.xs,
},
deviceInfo: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
deviceIcon: {
width: 48,
height: 48,
borderRadius: BorderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
},
deviceDetails: {
flex: 1,
},
deviceName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
deviceMeta: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
gap: 4,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
},
deviceStatus: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
},
deviceMetaSeparator: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
deviceRoom: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
detachButton: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.errorLight,
justifyContent: 'center',
alignItems: 'center',
},
// Empty State
emptyState: {
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
...Shadows.sm,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
emptyTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
emptyText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
textAlign: 'center',
marginBottom: Spacing.lg,
},
addDeviceButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
},
addDeviceButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// Detach All Button
detachAllButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.errorLight,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
marginTop: Spacing.lg,
gap: Spacing.sm,
},
detachAllText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.error,
},
// Info Card
infoCard: {
backgroundColor: AppColors.infoLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginTop: Spacing.xl,
},
infoHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: Spacing.xs,
},
infoTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.info,
},
infoText: {
fontSize: FontSizes.sm,
color: AppColors.info,
lineHeight: 20,
},
});

View File

@ -13,6 +13,7 @@ import {
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
Animated, Animated,
ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { useLocalSearchParams, router } from 'expo-router'; import { useLocalSearchParams, router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@ -23,6 +24,9 @@ import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { FullScreenError } from '@/components/ui/ErrorMessage'; import { FullScreenError } from '@/components/ui/ErrorMessage';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { SubscriptionPayment } from '@/components/SubscriptionPayment';
import { useToast } from '@/components/ui/Toast';
import MockDashboard from '@/components/MockDashboard';
import { import {
AppColors, AppColors,
BorderRadius, BorderRadius,
@ -94,73 +98,6 @@ function NoDevicesScreen({
); );
} }
// No Subscription Screen Component
function NoSubscriptionScreen({
beneficiary,
onSubscribe
}: {
beneficiary: Beneficiary;
onSubscribe: () => void;
}) {
const isExpired = beneficiary.subscription?.status === 'expired';
return (
<View style={styles.setupContainer}>
<View style={[styles.setupIconContainer, isExpired && { backgroundColor: AppColors.errorLight }]}>
<Ionicons
name={isExpired ? "time-outline" : "diamond-outline"}
size={48}
color={isExpired ? AppColors.error : AppColors.accent}
/>
</View>
<Text style={styles.setupTitle}>
{isExpired ? 'Subscription Expired' : 'Subscription Required'}
</Text>
<Text style={styles.setupSubtitle}>
{isExpired
? `Your subscription for ${beneficiary.name} has expired. Renew to continue monitoring.`
: `Activate a subscription to start monitoring ${beneficiary.name}'s wellness data.`
}
</Text>
<View style={styles.subscriptionPriceCard}>
<View style={styles.subscriptionPriceHeader}>
<Text style={styles.subscriptionPriceLabel}>WellNuo Pro</Text>
<View style={styles.subscriptionPriceBadge}>
<Text style={styles.subscriptionPriceAmount}>$49</Text>
<Text style={styles.subscriptionPriceUnit}>/month</Text>
</View>
</View>
<View style={styles.subscriptionPriceFeatures}>
<View style={styles.featureRow}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.featureRowText}>24/7 AI monitoring</Text>
</View>
<View style={styles.featureRow}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.featureRowText}>Unlimited chat with Julia AI</Text>
</View>
<View style={styles.featureRow}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.featureRowText}>Detailed activity reports</Text>
</View>
<View style={styles.featureRow}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.featureRowText}>Smart alerts & notifications</Text>
</View>
</View>
</View>
<Button
title={isExpired ? "Renew Subscription" : "Subscribe Now"}
onPress={onSubscribe}
fullWidth
size="lg"
/>
</View>
);
}
// Equipment status configuration // Equipment status configuration
const equipmentStatusInfo = { const equipmentStatusInfo = {
@ -292,6 +229,7 @@ function AwaitingEquipmentScreen({
export default function BeneficiaryDetailScreen() { export default function BeneficiaryDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary, removeLocalBeneficiary } = useBeneficiary(); const { setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary, removeLocalBeneficiary } = useBeneficiary();
const toast = useToast();
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null); const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
@ -327,6 +265,9 @@ export default function BeneficiaryDetailScreen() {
return 'ready'; return 'ready';
}, [beneficiary, isLoading]); }, [beneficiary, isLoading]);
// Dropdown menu state
const [isMenuVisible, setIsMenuVisible] = useState(false);
// Edit modal state // Edit modal state
const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
@ -399,20 +340,12 @@ export default function BeneficiaryDetailScreen() {
const handleGetSensors = () => { const handleGetSensors = () => {
// For now, show info or redirect to purchase // For now, show info or redirect to purchase
Alert.alert( toast.info(
'Get WellNuo Sensors', 'Get WellNuo Sensors',
'WellNuo sensor kits include motion sensors, door sensors, and temperature monitors.\n\nVisit wellnuo.com to order.', 'WellNuo sensor kits include motion sensors, door sensors, and temperature monitors. Visit wellnuo.com to order.'
[{ text: 'OK' }]
); );
}; };
const handleSubscribe = () => {
router.push({
pathname: '/(tabs)/beneficiaries/[id]/purchase',
params: { id: id! },
});
};
const handleMarkReceived = async () => { const handleMarkReceived = async () => {
if (!beneficiary || !id) return; if (!beneficiary || !id) return;
@ -427,7 +360,7 @@ export default function BeneficiaryDetailScreen() {
} }
// For API beneficiaries, would call backend here // For API beneficiaries, would call backend here
} catch (err) { } catch (err) {
Alert.alert('Error', 'Failed to update status'); toast.error('Error', 'Failed to update status');
} }
}; };
@ -452,7 +385,7 @@ export default function BeneficiaryDetailScreen() {
const handlePickAvatar = async () => { const handlePickAvatar = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') { if (status !== 'granted') {
Alert.alert('Permission needed', 'Please allow access to your photo library.'); toast.error('Permission needed', 'Please allow access to your photo library.');
return; return;
} }
@ -470,7 +403,7 @@ export default function BeneficiaryDetailScreen() {
const handleSaveEdit = async () => { const handleSaveEdit = async () => {
if (!editForm.name.trim()) { if (!editForm.name.trim()) {
Alert.alert('Error', 'Name is required'); toast.error('Error', 'Name is required');
return; return;
} }
@ -484,24 +417,18 @@ export default function BeneficiaryDetailScreen() {
if (updated) { if (updated) {
setBeneficiary(updated); setBeneficiary(updated);
setIsEditModalVisible(false); setIsEditModalVisible(false);
toast.success('Saved', 'Profile updated successfully');
} else { } else {
Alert.alert('Error', 'Failed to save changes.'); toast.error('Error', 'Failed to save changes.');
} }
} else { } else {
Alert.alert( toast.info('Demo Mode', 'Saving beneficiary data requires backend API.');
'Demo Mode', setIsEditModalVisible(false);
'Saving beneficiary data requires backend API.',
[{ text: 'OK', onPress: () => setIsEditModalVisible(false) }]
);
} }
}; };
const showComingSoon = (featureName: string) => { const showComingSoon = (featureName: string) => {
Alert.alert( toast.info('Coming Soon', `${featureName} is currently in development.`);
'Coming Soon',
`${featureName} is currently in development.`,
[{ text: 'OK' }]
);
}; };
const handleDeleteBeneficiary = () => { const handleDeleteBeneficiary = () => {
@ -520,7 +447,7 @@ export default function BeneficiaryDetailScreen() {
await removeLocalBeneficiary(parseInt(id, 10)); await removeLocalBeneficiary(parseInt(id, 10));
router.replace('/(tabs)'); router.replace('/(tabs)');
} catch (err) { } catch (err) {
Alert.alert('Error', 'Failed to remove beneficiary'); toast.error('Error', 'Failed to remove beneficiary');
} }
}, },
}, },
@ -566,9 +493,9 @@ export default function BeneficiaryDetailScreen() {
case 'no_subscription': case 'no_subscription':
return ( return (
<NoSubscriptionScreen <SubscriptionPayment
beneficiary={beneficiary} beneficiary={beneficiary}
onSubscribe={handleSubscribe} onSuccess={() => loadBeneficiary(false)}
/> />
); );
@ -628,16 +555,6 @@ export default function BeneficiaryDetailScreen() {
{/* Quick Actions */} {/* Quick Actions */}
<Text style={styles.sectionTitle}>Quick Actions</Text> <Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.actionsRow}> <View style={styles.actionsRow}>
<TouchableOpacity
style={styles.actionButton}
onPress={() => router.push(`/(tabs)/beneficiaries/${id}/dashboard`)}
>
<View style={[styles.actionButtonIcon, { backgroundColor: AppColors.primaryLighter }]}>
<Ionicons name="stats-chart" size={22} color={AppColors.primary} />
</View>
<Text style={styles.actionButtonText}>Dashboard</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.actionButton} style={styles.actionButton}
onPress={() => { onPress={() => {
@ -648,7 +565,17 @@ export default function BeneficiaryDetailScreen() {
<View style={[styles.actionButtonIcon, { backgroundColor: AppColors.accentLight }]}> <View style={[styles.actionButtonIcon, { backgroundColor: AppColors.accentLight }]}>
<Ionicons name="chatbubbles" size={22} color={AppColors.accent} /> <Ionicons name="chatbubbles" size={22} color={AppColors.accent} />
</View> </View>
<Text style={styles.actionButtonText}>Chat</Text> <Text style={styles.actionButtonText}>Chat with Julia</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionButton}
onPress={() => router.push(`/(tabs)/beneficiaries/${id}/equipment`)}
>
<View style={[styles.actionButtonIcon, { backgroundColor: AppColors.primaryLighter }]}>
<Ionicons name="hardware-chip" size={22} color={AppColors.primary} />
</View>
<Text style={styles.actionButtonText}>Equipment</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -745,6 +672,9 @@ export default function BeneficiaryDetailScreen() {
</TouchableOpacity> </TouchableOpacity>
</> </>
)} )}
{/* Activity Dashboard */}
<MockDashboard beneficiaryName={beneficiary.name} />
</ScrollView> </ScrollView>
); );
} }
@ -758,11 +688,107 @@ export default function BeneficiaryDetailScreen() {
<Ionicons name="arrow-back" size={22} color={AppColors.textPrimary} /> <Ionicons name="arrow-back" size={22} color={AppColors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiary.name}</Text> <Text style={styles.headerTitle}>{beneficiary.name}</Text>
<TouchableOpacity style={styles.headerButton} onPress={handleEditPress}> <View>
<Ionicons name="create-outline" size={22} color={AppColors.primary} /> <TouchableOpacity style={styles.headerButton} onPress={() => setIsMenuVisible(!isMenuVisible)}>
</TouchableOpacity> <Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
</TouchableOpacity>
{/* Dropdown Menu */}
{isMenuVisible && (
<View style={styles.dropdownMenu}>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
setCurrentBeneficiary(beneficiary);
router.push('/(tabs)/chat');
}}
>
<Ionicons name="chatbubbles-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Chat</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
router.push(`/(tabs)/beneficiaries/${id}/dashboard`);
}}
>
<Ionicons name="stats-chart-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Dashboard</Text>
</TouchableOpacity>
<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}>Share</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>
{isLocal && (
<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> </View>
{/* Backdrop to close menu */}
{isMenuVisible && (
<TouchableOpacity
style={styles.menuBackdrop}
activeOpacity={1}
onPress={() => setIsMenuVisible(false)}
/>
)}
{renderContent()} {renderContent()}
{/* Edit Modal */} {/* Edit Modal */}
@ -885,6 +911,7 @@ const styles = StyleSheet.create({
paddingVertical: Spacing.md, paddingVertical: Spacing.md,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: AppColors.border, borderBottomColor: AppColors.border,
zIndex: 1001,
}, },
headerButton: { headerButton: {
width: 40, width: 40,
@ -1537,4 +1564,37 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold, fontWeight: FontWeights.semibold,
color: AppColors.white, 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,
},
}); });

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
View, View,
Text, Text,
@ -10,32 +10,75 @@ import {
ScrollView, ScrollView,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
RefreshControl,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api';
import { import {
AppColors, AppColors,
BorderRadius, BorderRadius,
FontSizes, FontSizes,
FontWeights, FontWeights,
Spacing, Spacing,
Shadows,
} from '@/constants/theme'; } from '@/constants/theme';
type Role = 'caretaker' | 'guardian'; type Role = 'caretaker' | 'guardian';
interface Invitation {
id: string;
email: string;
role: string;
label?: string;
status: string;
created_at: string;
}
export default function ShareAccessScreen() { export default function ShareAccessScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary } = useBeneficiary(); const { currentBeneficiary } = useBeneficiary();
const { user } = useAuth();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [label, setLabel] = useState('');
const [role, setRole] = useState<Role>('caretaker'); const [role, setRole] = useState<Role>('caretaker');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [isLoadingInvitations, setIsLoadingInvitations] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const beneficiaryName = currentBeneficiary?.name || 'this person'; const beneficiaryName = currentBeneficiary?.name || 'this person';
const currentUserEmail = user?.email?.toLowerCase();
// Load invitations on mount
useEffect(() => {
if (id) {
loadInvitations();
}
}, [id]);
const loadInvitations = async () => {
if (!id) return;
try {
const response = await api.getInvitations(id);
if (response.ok && response.data) {
setInvitations(response.data.invitations || []);
}
} catch (error) {
console.error('Failed to load invitations:', error);
} finally {
setIsLoadingInvitations(false);
setRefreshing(false);
}
};
const onRefresh = useCallback(() => {
setRefreshing(true);
loadInvitations();
}, [id]);
const validateEmail = (email: string): boolean => { const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@ -44,35 +87,47 @@ export default function ShareAccessScreen() {
const handleSendInvite = async () => { const handleSendInvite = async () => {
const trimmedEmail = email.trim().toLowerCase(); const trimmedEmail = email.trim().toLowerCase();
const trimmedLabel = label.trim();
if (!trimmedEmail) { if (!trimmedEmail) {
Alert.alert('Email Required', 'Please enter an email address.'); Alert.alert('Error', 'Please enter an email address.');
return; return;
} }
if (!validateEmail(trimmedEmail)) { if (!validateEmail(trimmedEmail)) {
Alert.alert('Invalid Email', 'Please enter a valid email address.'); Alert.alert('Error', 'Please enter a valid email address.');
return;
}
// Check if inviting self
if (currentUserEmail && trimmedEmail === currentUserEmail) {
Alert.alert('Error', 'You cannot invite yourself.');
return;
}
// Check if already invited
const alreadyInvited = invitations.some(inv => inv.email.toLowerCase() === trimmedEmail);
if (alreadyInvited) {
Alert.alert('Error', 'This person has already been invited.');
return; return;
} }
setIsLoading(true); setIsLoading(true);
try { try {
// TODO: Send invitation via API const response = await api.sendInvitation({
// await api.sendInvitation({ beneficiaryId: id!,
// beneficiaryId: id, email: trimmedEmail,
// email: trimmedEmail, role: role,
// label: trimmedLabel, });
// role: role,
// });
// For now, show success message if (response.ok) {
Alert.alert( setEmail('');
'Invitation Sent', setRole('caretaker');
`An invitation has been sent to ${trimmedEmail} to ${role === 'guardian' ? 'manage' : 'view'} ${beneficiaryName}.`, loadInvitations();
[{ text: 'OK', onPress: () => router.back() }] Alert.alert('Success', `Invitation sent to ${trimmedEmail}`);
); } else {
Alert.alert('Error', response.error?.message || 'Failed to send invitation');
}
} catch (error) { } catch (error) {
console.error('Failed to send invitation:', error); console.error('Failed to send invitation:', error);
Alert.alert('Error', 'Failed to send invitation. Please try again.'); Alert.alert('Error', 'Failed to send invitation. Please try again.');
@ -81,6 +136,46 @@ export default function ShareAccessScreen() {
} }
}; };
const handleRemoveInvitation = (invitation: Invitation) => {
Alert.alert(
'Remove Access',
`Remove ${invitation.email} from accessing ${beneficiaryName}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
try {
const response = await api.deleteInvitation(invitation.id);
if (response.ok) {
loadInvitations();
} else {
Alert.alert('Error', 'Failed to remove access');
}
} catch (error) {
Alert.alert('Error', 'Failed to remove access');
}
},
},
]
);
};
const getRoleLabel = (role: string): string => {
if (role === 'owner' || role === 'guardian') return 'Guardian';
return 'Caretaker';
};
const getStatusColor = (status: string): string => {
switch (status) {
case 'accepted': return AppColors.success;
case 'pending': return AppColors.warning;
case 'rejected': return AppColors.error;
default: return AppColors.textMuted;
}
};
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */} {/* Header */}
@ -88,7 +183,7 @@ export default function ShareAccessScreen() {
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}> <TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} /> <Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Share Access</Text> <Text style={styles.headerTitle}>Access</Text>
<View style={styles.placeholder} /> <View style={styles.placeholder} />
</View> </View>
@ -99,23 +194,18 @@ export default function ShareAccessScreen() {
<ScrollView <ScrollView
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
> >
{/* Info Banner */} {/* Invite Section */}
<View style={styles.infoBanner}> <View style={styles.section}>
<Ionicons name="people" size={24} color={AppColors.primary} /> <Text style={styles.sectionTitle}>Invite Someone</Text>
<Text style={styles.infoBannerText}>
Invite family members or caregivers to help monitor {beneficiaryName}
</Text>
</View>
{/* Email Input */} <View style={styles.inputRow}>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Email Address</Text>
<View style={styles.inputContainer}>
<Ionicons name="mail-outline" size={20} color={AppColors.textMuted} />
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Enter email address" placeholder="Email address"
placeholderTextColor={AppColors.textMuted} placeholderTextColor={AppColors.textMuted}
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
@ -124,105 +214,95 @@ export default function ShareAccessScreen() {
keyboardType="email-address" keyboardType="email-address"
editable={!isLoading} editable={!isLoading}
/> />
</View> <TouchableOpacity
</View> style={[styles.sendButton, isLoading && styles.sendButtonDisabled]}
onPress={handleSendInvite}
{/* Label Input */} disabled={isLoading}
<View style={styles.inputGroup}> >
<Text style={styles.inputLabel}>Label (optional)</Text> {isLoading ? (
<View style={styles.inputContainer}> <ActivityIndicator size="small" color={AppColors.white} />
<Ionicons name="pricetag-outline" size={20} color={AppColors.textMuted} /> ) : (
<TextInput <Ionicons name="send" size={18} color={AppColors.white} />
style={styles.input}
placeholder="e.g., Sister, Nurse, Neighbor"
placeholderTextColor={AppColors.textMuted}
value={label}
onChangeText={setLabel}
autoCapitalize="words"
editable={!isLoading}
/>
</View>
</View>
{/* Role Selection */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Access Level</Text>
<TouchableOpacity
style={[styles.roleOption, role === 'caretaker' && styles.roleOptionSelected]}
onPress={() => setRole('caretaker')}
disabled={isLoading}
>
<View style={styles.roleHeader}>
<View style={[styles.roleIcon, role === 'caretaker' && styles.roleIconSelected]}>
<Ionicons
name="eye-outline"
size={20}
color={role === 'caretaker' ? AppColors.white : AppColors.primary}
/>
</View>
<View style={styles.roleInfo}>
<Text style={[styles.roleTitle, role === 'caretaker' && styles.roleTitleSelected]}>
Caretaker
</Text>
<Text style={styles.roleDescription}>
Can view activity and chat with Julia
</Text>
</View>
{role === 'caretaker' && (
<Ionicons name="checkmark-circle" size={24} color={AppColors.primary} />
)} )}
</View> </TouchableOpacity>
</TouchableOpacity> </View>
<TouchableOpacity {/* Role Toggle */}
style={[styles.roleOption, role === 'guardian' && styles.roleOptionSelected]} <View style={styles.roleToggle}>
onPress={() => setRole('guardian')} <TouchableOpacity
disabled={isLoading} style={[styles.roleButton, role === 'caretaker' && styles.roleButtonActive]}
> onPress={() => setRole('caretaker')}
<View style={styles.roleHeader}> >
<View style={[styles.roleIcon, role === 'guardian' && styles.roleIconSelected]}> <Text style={[styles.roleButtonText, role === 'caretaker' && styles.roleButtonTextActive]}>
<Ionicons Caretaker
name="shield-outline" </Text>
size={20} </TouchableOpacity>
color={role === 'guardian' ? AppColors.white : AppColors.accent} <TouchableOpacity
/> style={[styles.roleButton, role === 'guardian' && styles.roleButtonActive]}
</View> onPress={() => setRole('guardian')}
<View style={styles.roleInfo}> >
<Text style={[styles.roleTitle, role === 'guardian' && styles.roleTitleSelected]}> <Text style={[styles.roleButtonText, role === 'guardian' && styles.roleButtonTextActive]}>
Guardian Guardian
</Text> </Text>
<Text style={styles.roleDescription}> </TouchableOpacity>
Full access: edit info, manage subscription, invite others </View>
</Text>
</View> <Text style={styles.roleHint}>
{role === 'guardian' && ( {role === 'caretaker'
<Ionicons name="checkmark-circle" size={24} color={AppColors.accent} /> ? 'Can view activity and chat with Julia'
)} : 'Full access: edit info, manage subscription'}
</View> </Text>
</TouchableOpacity>
</View> </View>
{/* Send Button */} {/* People with Access */}
<TouchableOpacity <View style={styles.section}>
style={[styles.sendButton, isLoading && styles.sendButtonDisabled]} <Text style={styles.sectionTitle}>People with Access</Text>
onPress={handleSendInvite}
disabled={isLoading} {isLoadingInvitations ? (
> <View style={styles.loadingContainer}>
{isLoading ? ( <ActivityIndicator size="small" color={AppColors.primary} />
<ActivityIndicator color={AppColors.white} /> </View>
) : invitations.length === 0 ? (
<View style={styles.emptyState}>
<Ionicons name="people-outline" size={32} color={AppColors.textMuted} />
<Text style={styles.emptyText}>No one else has access yet</Text>
</View>
) : ( ) : (
<> <View style={styles.invitationsList}>
<Ionicons name="send" size={20} color={AppColors.white} /> {invitations.map((invitation) => (
<Text style={styles.sendButtonText}>Send Invitation</Text> <View key={invitation.id} style={styles.invitationItem}>
</> <View style={styles.invitationInfo}>
<View style={styles.invitationAvatar}>
<Text style={styles.avatarText}>
{invitation.email.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.invitationDetails}>
<Text style={styles.invitationEmail} numberOfLines={1}>
{invitation.email}
</Text>
<View style={styles.invitationMeta}>
<Text style={styles.invitationRole}>
{getRoleLabel(invitation.role)}
</Text>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(invitation.status) }]} />
<Text style={[styles.invitationStatus, { color: getStatusColor(invitation.status) }]}>
{invitation.status}
</Text>
</View>
</View>
</View>
<TouchableOpacity
style={styles.removeButton}
onPress={() => handleRemoveInvitation(invitation)}
>
<Ionicons name="close" size={18} color={AppColors.textMuted} />
</TouchableOpacity>
</View>
))}
</View>
)} )}
</TouchableOpacity> </View>
{/* Help Text */}
<Text style={styles.helpText}>
The person will receive an email with instructions to access {beneficiaryName}'s information.
</Text>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
@ -261,114 +341,148 @@ const styles = StyleSheet.create({
padding: Spacing.lg, padding: Spacing.lg,
paddingBottom: Spacing.xxl, paddingBottom: Spacing.xxl,
}, },
infoBanner: { section: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primaryLighter,
padding: Spacing.md,
borderRadius: BorderRadius.lg,
marginBottom: Spacing.xl, marginBottom: Spacing.xl,
gap: Spacing.md,
}, },
infoBannerText: { sectionTitle: {
flex: 1,
fontSize: FontSizes.sm, fontSize: FontSizes.sm,
color: AppColors.textPrimary, fontWeight: FontWeights.semibold,
lineHeight: 20,
},
inputGroup: {
marginBottom: Spacing.lg,
},
inputLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary, color: AppColors.textSecondary,
marginBottom: Spacing.sm, marginBottom: Spacing.md,
textTransform: 'uppercase',
letterSpacing: 0.5,
}, },
inputContainer: { inputRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', gap: Spacing.sm,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
paddingHorizontal: Spacing.md,
borderWidth: 1,
borderColor: AppColors.border,
}, },
input: { input: {
flex: 1, flex: 1,
paddingVertical: Spacing.md,
marginLeft: Spacing.sm,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
roleOption: {
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg, borderRadius: BorderRadius.lg,
padding: Spacing.md, paddingHorizontal: Spacing.md,
marginBottom: Spacing.sm, paddingVertical: Spacing.md,
borderWidth: 2, fontSize: FontSizes.base,
color: AppColors.textPrimary,
borderWidth: 1,
borderColor: AppColors.border, borderColor: AppColors.border,
}, },
roleOptionSelected: {
borderColor: AppColors.primary,
backgroundColor: AppColors.primaryLighter,
},
roleHeader: {
flexDirection: 'row',
alignItems: 'center',
},
roleIcon: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
roleIconSelected: {
backgroundColor: AppColors.primary,
},
roleInfo: {
flex: 1,
},
roleTitle: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
roleTitleSelected: {
color: AppColors.primary,
},
roleDescription: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginTop: 2,
},
sendButton: { sendButton: {
flexDirection: 'row', width: 48,
alignItems: 'center', height: 48,
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg, borderRadius: BorderRadius.lg,
marginTop: Spacing.lg, backgroundColor: AppColors.primary,
gap: Spacing.sm, justifyContent: 'center',
...Shadows.primary, alignItems: 'center',
}, },
sendButtonDisabled: { sendButtonDisabled: {
opacity: 0.7, opacity: 0.7,
}, },
sendButtonText: { roleToggle: {
fontSize: FontSizes.base, flexDirection: 'row',
backgroundColor: AppColors.surfaceSecondary,
borderRadius: BorderRadius.lg,
padding: 4,
marginTop: Spacing.md,
},
roleButton: {
flex: 1,
paddingVertical: Spacing.sm,
alignItems: 'center',
borderRadius: BorderRadius.md,
},
roleButtonActive: {
backgroundColor: AppColors.surface,
},
roleButtonText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary,
},
roleButtonTextActive: {
color: AppColors.primary,
fontWeight: FontWeights.semibold,
},
roleHint: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: Spacing.sm,
textAlign: 'center',
},
loadingContainer: {
padding: Spacing.xl,
alignItems: 'center',
},
emptyState: {
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
},
emptyText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginTop: Spacing.sm,
},
invitationsList: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
overflow: 'hidden',
},
invitationItem: {
flexDirection: 'row',
alignItems: 'center',
padding: Spacing.md,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
invitationInfo: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
invitationAvatar: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: AppColors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
avatarText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold, fontWeight: FontWeights.semibold,
color: AppColors.white, color: AppColors.white,
}, },
helpText: { invitationDetails: {
flex: 1,
},
invitationEmail: {
fontSize: FontSizes.sm, fontSize: FontSizes.sm,
color: AppColors.textMuted, fontWeight: FontWeights.medium,
textAlign: 'center', color: AppColors.textPrimary,
marginTop: Spacing.lg, },
lineHeight: 20, invitationMeta: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
},
invitationRole: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
marginHorizontal: Spacing.xs,
},
invitationStatus: {
fontSize: FontSizes.xs,
textTransform: 'capitalize',
},
removeButton: {
padding: Spacing.xs,
}, },
}); });

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
View, View,
Text, Text,
@ -10,13 +10,12 @@ import {
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { usePaymentSheet } from '@stripe/stripe-react-native'; import { usePaymentSheet } from '@stripe/stripe-react-native';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
import { PageHeader } from '@/components/PageHeader';
import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useSubscription } from '@/hooks/useSubscription'; import type { Beneficiary, BeneficiarySubscription } from '@/types';
import type { BeneficiarySubscription } from '@/types';
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe'; const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const SUBSCRIPTION_PRICE = 49; // $49/month const SUBSCRIPTION_PRICE = 49; // $49/month
@ -42,16 +41,44 @@ function PlanFeature({ text, included }: PlanFeatureProps) {
} }
export default function SubscriptionScreen() { export default function SubscriptionScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet(); const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const { user } = useAuth(); const { user } = useAuth();
const { currentBeneficiary, updateLocalBeneficiary } = useBeneficiary(); const { getBeneficiaryById, updateLocalBeneficiary } = useBeneficiary();
const { isActive, daysRemaining, subscription, beneficiaryName } = useSubscription();
useEffect(() => {
loadBeneficiary();
}, [id]);
const loadBeneficiary = async () => {
if (!id) return;
try {
const data = await getBeneficiaryById(id);
setBeneficiary(data);
} catch (error) {
console.error('Failed to load beneficiary:', error);
} finally {
setIsLoading(false);
}
};
const subscription = beneficiary?.subscription;
const isActive = subscription?.status === 'active' &&
subscription?.endDate &&
new Date(subscription.endDate) > new Date();
const daysRemaining = subscription?.endDate
? Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: 0;
const handleSubscribe = async () => { const handleSubscribe = async () => {
if (!currentBeneficiary) { if (!beneficiary) {
Alert.alert('No Beneficiary', 'Please select a loved one first.'); Alert.alert('Error', 'Beneficiary data not loaded.');
return; return;
} }
@ -71,8 +98,8 @@ export default function SubscriptionScreen() {
type: 'subscription', type: 'subscription',
planType: 'monthly', planType: 'monthly',
userId: user?.user_id || 'guest', userId: user?.user_id || 'guest',
beneficiaryId: currentBeneficiary.id, beneficiaryId: beneficiary.id,
beneficiaryName: currentBeneficiary.name, beneficiaryName: beneficiary.name,
}, },
}), }),
}); });
@ -130,13 +157,16 @@ export default function SubscriptionScreen() {
price: SUBSCRIPTION_PRICE, price: SUBSCRIPTION_PRICE,
}; };
await updateLocalBeneficiary(currentBeneficiary.id, { await updateLocalBeneficiary(beneficiary.id, {
subscription: newSubscription, subscription: newSubscription,
}); });
// Reload beneficiary to get updated subscription
await loadBeneficiary();
Alert.alert( Alert.alert(
'Subscription Activated!', 'Subscription Activated!',
`Subscription for ${currentBeneficiary.name} is now active until ${formatDate(endDate)}.`, `Subscription for ${beneficiary.name} is now active until ${formatDate(endDate)}.`,
[{ text: 'Great!' }] [{ text: 'Great!' }]
); );
} catch (error) { } catch (error) {
@ -170,18 +200,40 @@ export default function SubscriptionScreen() {
}); });
}; };
// No beneficiary selected if (isLoading) {
if (!currentBeneficiary) {
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<PageHeader title="Subscription" /> <View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Subscription</Text>
<View style={styles.placeholder} />
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
</View>
</SafeAreaView>
);
}
if (!beneficiary) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Subscription</Text>
<View style={styles.placeholder} />
</View>
<View style={styles.noBeneficiaryContainer}> <View style={styles.noBeneficiaryContainer}>
<View style={styles.noBeneficiaryIcon}> <View style={styles.noBeneficiaryIcon}>
<Ionicons name="person-outline" size={48} color={AppColors.textMuted} /> <Ionicons name="person-outline" size={48} color={AppColors.textMuted} />
</View> </View>
<Text style={styles.noBeneficiaryTitle}>No Loved One Selected</Text> <Text style={styles.noBeneficiaryTitle}>Beneficiary Not Found</Text>
<Text style={styles.noBeneficiaryText}> <Text style={styles.noBeneficiaryText}>
Please select a loved one from the home screen to manage their subscription. Unable to load beneficiary information.
</Text> </Text>
</View> </View>
</SafeAreaView> </SafeAreaView>
@ -190,18 +242,26 @@ export default function SubscriptionScreen() {
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<PageHeader title="Subscription" /> {/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Subscription</Text>
<View style={styles.placeholder} />
</View>
<ScrollView showsVerticalScrollIndicator={false}> <ScrollView showsVerticalScrollIndicator={false}>
{/* Beneficiary Info */} {/* Beneficiary Info */}
<View style={styles.beneficiaryBanner}> <View style={styles.beneficiaryBanner}>
<View style={styles.beneficiaryAvatar}> <View style={styles.beneficiaryAvatar}>
<Text style={styles.beneficiaryAvatarText}> <Text style={styles.beneficiaryAvatarText}>
{currentBeneficiary.name.charAt(0).toUpperCase()} {beneficiary.name.charAt(0).toUpperCase()}
</Text> </Text>
</View> </View>
<View style={styles.beneficiaryInfo}> <View style={styles.beneficiaryInfo}>
<Text style={styles.beneficiaryLabel}>Subscription for</Text> <Text style={styles.beneficiaryLabel}>Subscription for</Text>
<Text style={styles.beneficiaryName}>{currentBeneficiary.name}</Text> <Text style={styles.beneficiaryName}>{beneficiary.name}</Text>
</View> </View>
</View> </View>
@ -233,7 +293,7 @@ export default function SubscriptionScreen() {
<Text style={styles.statusTitle}> <Text style={styles.statusTitle}>
{subscription?.status === 'expired' {subscription?.status === 'expired'
? 'Subscription has expired' ? 'Subscription has expired'
: `Subscribe for ${beneficiaryName}`} : `Subscribe for ${beneficiary.name}`}
</Text> </Text>
<Text style={styles.statusDescription}> <Text style={styles.statusDescription}>
Get full access to monitoring features Get full access to monitoring features
@ -313,6 +373,31 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
}, },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 32,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
// No beneficiary state // No beneficiary state
noBeneficiaryContainer: { noBeneficiaryContainer: {
flex: 1, flex: 1,
@ -331,7 +416,7 @@ const styles = StyleSheet.create({
}, },
noBeneficiaryTitle: { noBeneficiaryTitle: {
fontSize: FontSizes.xl, fontSize: FontSizes.xl,
fontWeight: '700', fontWeight: FontWeights.bold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
marginBottom: Spacing.sm, marginBottom: Spacing.sm,
}, },
@ -361,7 +446,7 @@ const styles = StyleSheet.create({
}, },
beneficiaryAvatarText: { beneficiaryAvatarText: {
fontSize: FontSizes.xl, fontSize: FontSizes.xl,
fontWeight: '700', fontWeight: FontWeights.bold,
color: AppColors.white, color: AppColors.white,
}, },
beneficiaryInfo: { beneficiaryInfo: {
@ -373,7 +458,7 @@ const styles = StyleSheet.create({
}, },
beneficiaryName: { beneficiaryName: {
fontSize: FontSizes.lg, fontSize: FontSizes.lg,
fontWeight: '700', fontWeight: FontWeights.bold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
}, },
// Status banner // Status banner
@ -393,7 +478,7 @@ const styles = StyleSheet.create({
}, },
activeBadgeText: { activeBadgeText: {
fontSize: FontSizes.sm, fontSize: FontSizes.sm,
fontWeight: '700', fontWeight: FontWeights.bold,
color: AppColors.success, color: AppColors.success,
}, },
inactiveBadge: { inactiveBadge: {
@ -407,12 +492,12 @@ const styles = StyleSheet.create({
}, },
inactiveBadgeText: { inactiveBadgeText: {
fontSize: FontSizes.sm, fontSize: FontSizes.sm,
fontWeight: '700', fontWeight: FontWeights.bold,
color: AppColors.error, color: AppColors.error,
}, },
statusTitle: { statusTitle: {
fontSize: FontSizes.xl, fontSize: FontSizes.xl,
fontWeight: '700', fontWeight: FontWeights.bold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
marginTop: Spacing.md, marginTop: Spacing.md,
textAlign: 'center', textAlign: 'center',
@ -429,7 +514,7 @@ const styles = StyleSheet.create({
}, },
daysRemainingNumber: { daysRemainingNumber: {
fontSize: 48, fontSize: 48,
fontWeight: '700', fontWeight: FontWeights.bold,
color: AppColors.primary, color: AppColors.primary,
}, },
daysRemainingLabel: { daysRemainingLabel: {
@ -459,7 +544,7 @@ const styles = StyleSheet.create({
}, },
proBadgeText: { proBadgeText: {
fontSize: FontSizes.lg, fontSize: FontSizes.lg,
fontWeight: '700', fontWeight: FontWeights.bold,
color: AppColors.primary, color: AppColors.primary,
}, },
priceContainer: { priceContainer: {
@ -469,7 +554,7 @@ const styles = StyleSheet.create({
}, },
priceAmount: { priceAmount: {
fontSize: 48, fontSize: 48,
fontWeight: '700', fontWeight: FontWeights.bold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
}, },
pricePeriod: { pricePeriod: {
@ -509,7 +594,7 @@ const styles = StyleSheet.create({
}, },
subscribeButtonText: { subscribeButtonText: {
fontSize: FontSizes.base, fontSize: FontSizes.base,
fontWeight: '600', fontWeight: FontWeights.semibold,
color: AppColors.white, color: AppColors.white,
}, },
securityBadge: { securityBadge: {

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
View, View,
Text, Text,
@ -12,6 +12,7 @@ import {
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { api } from '@/services/api'; import { api } from '@/services/api';
@ -78,14 +79,18 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
}; };
return ( return (
<TouchableOpacity style={styles.card} onPress={handlePress} activeOpacity={0.7}> <TouchableOpacity
style={[styles.card, hasNoSubscription && styles.cardNoSubscription]}
onPress={handlePress}
activeOpacity={0.7}
>
{/* Avatar */} {/* Avatar */}
<View style={styles.avatarWrapper}> <View style={styles.avatarWrapper}>
{beneficiary.avatar ? ( {beneficiary.avatar ? (
<Image source={{ uri: beneficiary.avatar }} style={styles.avatarImage} /> <Image source={{ uri: beneficiary.avatar }} style={styles.avatarImage} />
) : ( ) : (
<View style={styles.avatar}> <View style={[styles.avatar, hasNoSubscription && styles.avatarNoSubscription]}>
<Text style={styles.avatarText}> <Text style={[styles.avatarText, hasNoSubscription && styles.avatarTextNoSubscription]}>
{beneficiary.name.charAt(0).toUpperCase()} {beneficiary.name.charAt(0).toUpperCase()}
</Text> </Text>
</View> </View>
@ -104,15 +109,15 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
</Text> </Text>
</View> </View>
)} )}
{/* No subscription warning */}
{hasNoSubscription && (
<View style={styles.noSubscriptionBadge}>
<Ionicons name="alert-circle" size={14} color={AppColors.error} />
<Text style={styles.noSubscriptionText}>No subscription</Text>
</View>
)}
</View> </View>
{/* Warning icon if no subscription (only for active equipment) */}
{hasNoSubscription && (
<View style={styles.warningContainer}>
<Ionicons name="warning" size={20} color={AppColors.warning} />
</View>
)}
{/* Action button or Arrow */} {/* Action button or Arrow */}
{equipmentStatus === 'delivered' ? ( {equipmentStatus === 'delivered' ? (
<TouchableOpacity <TouchableOpacity
@ -140,10 +145,12 @@ export default function HomeScreen() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Load beneficiaries from API and combine with local ones // Load beneficiaries when screen is focused (after editing profile, etc.)
useEffect(() => { useFocusEffect(
loadBeneficiaries(); useCallback(() => {
}, [localBeneficiaries]); loadBeneficiaries();
}, [localBeneficiaries])
);
const loadBeneficiaries = async () => { const loadBeneficiaries = async () => {
setIsLoading(true); setIsLoading(true);
@ -436,8 +443,14 @@ const styles = StyleSheet.create({
padding: Spacing.md, padding: Spacing.md,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
borderWidth: 2,
borderColor: 'transparent',
...Shadows.sm, ...Shadows.sm,
}, },
cardNoSubscription: {
borderColor: AppColors.error,
backgroundColor: AppColors.errorLight,
},
avatarWrapper: { avatarWrapper: {
position: 'relative', position: 'relative',
}, },
@ -459,6 +472,12 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.bold, fontWeight: FontWeights.bold,
color: AppColors.primary, color: AppColors.primary,
}, },
avatarNoSubscription: {
backgroundColor: AppColors.errorLight,
},
avatarTextNoSubscription: {
color: AppColors.error,
},
info: { info: {
flex: 1, flex: 1,
marginLeft: Spacing.md, marginLeft: Spacing.md,
@ -469,8 +488,16 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold, fontWeight: FontWeights.semibold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
}, },
warningContainer: { noSubscriptionBadge: {
marginRight: Spacing.sm, flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
gap: 4,
},
noSubscriptionText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
color: AppColors.error,
}, },
statusBadge: { statusBadge: {
flexDirection: 'row', flexDirection: 'row',

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@ -12,6 +12,7 @@ import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as Clipboard from 'expo-clipboard';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { ProfileDrawer } from '@/components/ProfileDrawer'; import { ProfileDrawer } from '@/components/ProfileDrawer';
@ -25,6 +26,20 @@ import {
AvatarSizes, AvatarSizes,
} from '@/constants/theme'; } from '@/constants/theme';
// Generate stable 5-digit invite code from user identifier
const generateInviteCode = (identifier: string): string => {
// Simple hash to get a stable code
let hash = 0;
for (let i = 0; i < identifier.length; i++) {
const char = identifier.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
// Convert to 5-digit code (10000-99999 range)
const code = 10000 + (Math.abs(hash) % 90000);
return code.toString();
};
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@ -106,6 +121,17 @@ export default function ProfileScreen() {
const userName = user?.user_name || 'User'; const userName = user?.user_name || 'User';
const userInitial = userName.charAt(0).toUpperCase(); const userInitial = userName.charAt(0).toUpperCase();
// Generate invite code based on user email or id
const inviteCode = useMemo(() => {
const identifier = user?.email || user?.user_id?.toString() || 'default';
return generateInviteCode(identifier);
}, [user?.email, user?.user_id]);
const handleCopyInviteCode = async () => {
await Clipboard.setStringAsync(inviteCode);
Alert.alert('Copied!', `Invite code "${inviteCode}" copied to clipboard`);
};
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
{/* Header */} {/* Header */}
@ -142,6 +168,19 @@ export default function ProfileScreen() {
<Text style={styles.userName}>{userName}</Text> <Text style={styles.userName}>{userName}</Text>
<Text style={styles.userEmail}>{user?.email || ''}</Text> <Text style={styles.userEmail}>{user?.email || ''}</Text>
{/* Invite Code */}
<TouchableOpacity style={styles.inviteCodeSection} onPress={handleCopyInviteCode}>
<View style={styles.inviteCodeBadge}>
<Ionicons name="gift-outline" size={16} color={AppColors.primary} />
<Text style={styles.inviteCodeLabel}>Your Invite Code</Text>
</View>
<View style={styles.inviteCodeBox}>
<Text style={styles.inviteCodeText}>{inviteCode}</Text>
<Ionicons name="copy-outline" size={18} color={AppColors.primary} />
</View>
<Text style={styles.inviteCodeHint}>Tap to copy · Share with friends for rewards</Text>
</TouchableOpacity>
</View> </View>
{/* Menu Items */} {/* Menu Items */}
@ -150,8 +189,8 @@ export default function ProfileScreen() {
style={styles.menuItem} style={styles.menuItem}
onPress={() => router.push('/(tabs)/profile/edit')} onPress={() => router.push('/(tabs)/profile/edit')}
> >
<View style={[styles.menuIcon, { backgroundColor: AppColors.primarySubtle }]}> <View style={styles.menuIcon}>
<Ionicons name="person-outline" size={22} color={AppColors.primary} /> <Ionicons name="person-outline" size={22} color={AppColors.textSecondary} />
</View> </View>
<Text style={styles.menuLabel}>Edit Profile</Text> <Text style={styles.menuLabel}>Edit Profile</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} /> <Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
@ -161,8 +200,8 @@ export default function ProfileScreen() {
style={styles.menuItem} style={styles.menuItem}
onPress={() => router.push('/(tabs)')} onPress={() => router.push('/(tabs)')}
> >
<View style={[styles.menuIcon, { backgroundColor: AppColors.accentLight }]}> <View style={styles.menuIcon}>
<Ionicons name="people-outline" size={22} color={AppColors.accent} /> <Ionicons name="people-outline" size={22} color={AppColors.textSecondary} />
</View> </View>
<Text style={styles.menuLabel}>My Loved Ones</Text> <Text style={styles.menuLabel}>My Loved Ones</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} /> <Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
@ -172,21 +211,10 @@ export default function ProfileScreen() {
style={styles.menuItem} style={styles.menuItem}
onPress={() => setDrawerVisible(true)} onPress={() => setDrawerVisible(true)}
> >
<View style={[styles.menuIcon, { backgroundColor: AppColors.surfaceSecondary }]}> <View style={styles.menuIcon}>
<Ionicons name="settings-outline" size={22} color={AppColors.textSecondary} /> <Ionicons name="settings-outline" size={22} color={AppColors.textSecondary} />
</View> </View>
<Text style={styles.menuLabel}>App Settings</Text> <Text style={styles.menuLabel}>Settings</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
<TouchableOpacity
style={styles.menuItem}
onPress={() => router.push('/(tabs)/profile/help')}
>
<View style={[styles.menuIcon, { backgroundColor: AppColors.successLight }]}>
<Ionicons name="help-circle-outline" size={22} color={AppColors.success} />
</View>
<Text style={styles.menuLabel}>Help & Support</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} /> <Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
@ -194,7 +222,7 @@ export default function ProfileScreen() {
style={[styles.menuItem, styles.menuItemLast]} style={[styles.menuItem, styles.menuItemLast]}
onPress={handleLogout} onPress={handleLogout}
> >
<View style={[styles.menuIcon, { backgroundColor: AppColors.errorLight }]}> <View style={[styles.menuIcon, styles.menuIconDanger]}>
<Ionicons name="log-out-outline" size={22} color={AppColors.error} /> <Ionicons name="log-out-outline" size={22} color={AppColors.error} />
</View> </View>
<Text style={[styles.menuLabel, { color: AppColors.error }]}>Log Out</Text> <Text style={[styles.menuLabel, { color: AppColors.error }]}>Log Out</Text>
@ -306,6 +334,46 @@ const styles = StyleSheet.create({
fontSize: FontSizes.sm, fontSize: FontSizes.sm,
color: AppColors.textSecondary, color: AppColors.textSecondary,
}, },
// Invite Code
inviteCodeSection: {
marginTop: Spacing.lg,
alignItems: 'center',
paddingTop: Spacing.md,
borderTopWidth: 1,
borderTopColor: AppColors.border,
width: '100%',
},
inviteCodeBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
marginBottom: Spacing.sm,
},
inviteCodeLabel: {
fontSize: FontSizes.xs,
color: AppColors.primary,
fontWeight: FontWeights.medium,
},
inviteCodeBox: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primaryLighter,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
borderRadius: BorderRadius.lg,
gap: Spacing.sm,
},
inviteCodeText: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.primary,
letterSpacing: 3,
},
inviteCodeHint: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: Spacing.xs,
},
// Menu Section // Menu Section
menuSection: { menuSection: {
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
@ -328,10 +396,14 @@ const styles = StyleSheet.create({
width: 40, width: 40,
height: 40, height: 40,
borderRadius: BorderRadius.md, borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: Spacing.md, marginRight: Spacing.md,
}, },
menuIconDanger: {
backgroundColor: AppColors.errorLight,
},
menuLabel: { menuLabel: {
flex: 1, flex: 1,
fontSize: FontSizes.base, fontSize: FontSizes.base,

View File

@ -7,6 +7,7 @@ import 'react-native-reanimated';
import { StripeProvider } from '@stripe/stripe-react-native'; import { StripeProvider } from '@stripe/stripe-react-native';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToastProvider } from '@/components/ui/Toast';
import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { AuthProvider, useAuth } from '@/contexts/AuthContext';
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext'; import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
@ -93,7 +94,9 @@ export default function RootLayout() {
> >
<AuthProvider> <AuthProvider>
<BeneficiaryProvider> <BeneficiaryProvider>
<RootLayoutNav /> <ToastProvider>
<RootLayoutNav />
</ToastProvider>
</BeneficiaryProvider> </BeneficiaryProvider>
</AuthProvider> </AuthProvider>
</StripeProvider> </StripeProvider>

View File

@ -46,7 +46,7 @@ function DrawerItem({ icon, label, onPress, rightElement, danger, badge }: Drawe
<Ionicons <Ionicons
name={icon} name={icon}
size={20} size={20}
color={danger ? AppColors.error : AppColors.primary} color={danger ? AppColors.error : AppColors.textSecondary}
/> />
</View> </View>
<Text style={[styles.drawerItemLabel, danger && styles.dangerText]}> <Text style={[styles.drawerItemLabel, danger && styles.dangerText]}>
@ -150,7 +150,7 @@ export function ProfileDrawer({
<Text style={styles.sectionTitle}>Preferences</Text> <Text style={styles.sectionTitle}>Preferences</Text>
<View style={styles.sectionCard}> <View style={styles.sectionCard}>
<DrawerItem <DrawerItem
icon="notifications" icon="notifications-outline"
label="Push Notifications" label="Push Notifications"
rightElement={ rightElement={
<Switch <Switch
@ -164,7 +164,7 @@ export function ProfileDrawer({
/> />
<View style={styles.divider} /> <View style={styles.divider} />
<DrawerItem <DrawerItem
icon="mail" icon="mail-outline"
label="Email Notifications" label="Email Notifications"
rightElement={ rightElement={
<Switch <Switch
@ -183,12 +183,6 @@ export function ProfileDrawer({
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text> <Text style={styles.sectionTitle}>Account</Text>
<View style={styles.sectionCard}> <View style={styles.sectionCard}>
<DrawerItem
icon="person-outline"
label="Edit Profile"
onPress={() => handleNavigate('/(tabs)/profile/edit')}
/>
<View style={styles.divider} />
<DrawerItem <DrawerItem
icon="language-outline" icon="language-outline"
label="Language" label="Language"
@ -234,13 +228,6 @@ export function ProfileDrawer({
</View> </View>
</View> </View>
{/* Logout */}
<View style={[styles.section, styles.logoutSection]}>
<TouchableOpacity style={styles.logoutButton} onPress={onLogout}>
<Ionicons name="log-out-outline" size={20} color={AppColors.error} />
<Text style={styles.logoutText}>Log Out</Text>
</TouchableOpacity>
</View>
</ScrollView> </ScrollView>
{/* Version */} {/* Version */}
@ -342,7 +329,7 @@ const styles = StyleSheet.create({
width: 36, width: 36,
height: 36, height: 36,
borderRadius: BorderRadius.md, borderRadius: BorderRadius.md,
backgroundColor: AppColors.primarySubtle, backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },

View File

@ -0,0 +1,414 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { usePaymentSheet } from '@stripe/stripe-react-native';
import { useAuth } from '@/contexts/AuthContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useToast } from '@/components/ui/Toast';
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
import type { Beneficiary, BeneficiarySubscription } from '@/types';
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const SUBSCRIPTION_PRICE = 49; // $49/month
interface SubscriptionPaymentProps {
beneficiary: Beneficiary;
onSuccess?: () => void;
compact?: boolean; // For inline use vs full page
}
export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }: SubscriptionPaymentProps) {
const [isProcessing, setIsProcessing] = useState(false);
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const { user } = useAuth();
const { updateLocalBeneficiary } = useBeneficiary();
const toast = useToast();
const isExpired = beneficiary?.subscription?.status === 'expired';
const handleSubscribe = async () => {
setIsProcessing(true);
try {
// 1. Create Payment Sheet on our server
const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: user?.email || 'guest@wellnuo.com',
amount: SUBSCRIPTION_PRICE * 100, // Convert to cents ($49.00)
metadata: {
type: 'subscription',
planType: 'monthly',
userId: user?.user_id || 'guest',
beneficiaryId: beneficiary.id,
beneficiaryName: beneficiary.name,
},
}),
});
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: user?.email || '',
},
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') {
setIsProcessing(false);
return;
}
throw new Error(presentError.message);
}
// 4. Payment successful! Save subscription to beneficiary
const now = new Date();
const endDate = new Date(now);
endDate.setMonth(endDate.getMonth() + 1); // 1 month subscription
const newSubscription: BeneficiarySubscription = {
status: 'active',
startDate: now.toISOString(),
endDate: endDate.toISOString(),
planType: 'monthly',
price: SUBSCRIPTION_PRICE,
};
await updateLocalBeneficiary(beneficiary.id, {
subscription: newSubscription,
});
toast.success(
'Subscription Activated!',
`Subscription for ${beneficiary.name} is now active.`
);
onSuccess?.();
} catch (error) {
console.error('Payment error:', error);
toast.error(
'Payment Failed',
error instanceof Error ? error.message : 'Something went wrong. Please try again.'
);
} finally {
setIsProcessing(false);
}
};
const features = [
'24/7 AI wellness monitoring',
'Unlimited Julia AI chat',
'Detailed activity reports',
'Smart alerts & notifications',
];
if (compact) {
// Compact version for inline use
return (
<View style={styles.compactContainer}>
<View style={styles.compactHeader}>
<View style={styles.compactIconContainer}>
<Ionicons
name={isExpired ? 'time-outline' : 'diamond-outline'}
size={32}
color={isExpired ? AppColors.error : AppColors.accent}
/>
</View>
<View style={styles.compactInfo}>
<Text style={styles.compactTitle}>
{isExpired ? 'Subscription Expired' : 'Subscription Required'}
</Text>
<Text style={styles.compactPrice}>
${SUBSCRIPTION_PRICE}/month
</Text>
</View>
</View>
<TouchableOpacity
style={[styles.subscribeButton, isProcessing && styles.buttonDisabled]}
onPress={handleSubscribe}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.subscribeButtonText}>
{isExpired ? 'Renew Now' : 'Subscribe Now'}
</Text>
</>
)}
</TouchableOpacity>
</View>
);
}
// Full version
return (
<View style={styles.container}>
{/* Icon */}
<View style={[styles.iconContainer, isExpired && { backgroundColor: AppColors.errorLight }]}>
<Ionicons
name={isExpired ? 'time-outline' : 'diamond-outline'}
size={56}
color={isExpired ? AppColors.error : AppColors.accent}
/>
</View>
{/* Title */}
<Text style={styles.title}>
{isExpired ? 'Subscription Expired' : 'Subscription Required'}
</Text>
<Text style={styles.subtitle}>
{isExpired
? `Your subscription for ${beneficiary.name} has expired. Renew now to continue monitoring their wellness.`
: `Activate a subscription to view ${beneficiary.name}'s dashboard and wellness data.`}
</Text>
{/* Price Card */}
<View style={styles.priceCard}>
<View style={styles.priceHeader}>
<View>
<Text style={styles.planName}>WellNuo Pro</Text>
<Text style={styles.planDesc}>Full access to all features</Text>
</View>
<View style={styles.priceBadge}>
<Text style={styles.priceAmount}>${SUBSCRIPTION_PRICE}</Text>
<Text style={styles.priceUnit}>/month</Text>
</View>
</View>
<View style={styles.features}>
{features.map((feature, index) => (
<View key={index} style={styles.featureRow}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
</View>
</View>
{/* Subscribe Button */}
<TouchableOpacity
style={[styles.subscribeButtonFull, isProcessing && styles.buttonDisabled]}
onPress={handleSubscribe}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={22} color={AppColors.white} />
<Text style={styles.subscribeButtonTextFull}>
{isExpired ? 'Renew Subscription' : 'Subscribe Now'}
</Text>
</>
)}
</TouchableOpacity>
{/* Security Badge */}
<View style={styles.securityBadge}>
<Ionicons name="lock-closed" size={14} color={AppColors.success} />
<Text style={styles.securityText}>Secure payment powered by Stripe</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
// Full version styles
container: {
flex: 1,
padding: Spacing.xl,
alignItems: 'center',
justifyContent: 'center',
},
iconContainer: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: AppColors.accentLight,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.lg,
},
title: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
textAlign: 'center',
marginBottom: Spacing.sm,
},
subtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 24,
marginBottom: Spacing.xl,
paddingHorizontal: Spacing.md,
},
priceCard: {
width: '100%',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.xl,
...Shadows.sm,
},
priceHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.md,
},
planName: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
planDesc: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginTop: 2,
},
priceBadge: {
flexDirection: 'row',
alignItems: 'baseline',
},
priceAmount: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
priceUnit: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginLeft: 2,
},
features: {
gap: Spacing.sm,
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
featureText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
subscribeButtonFull: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
width: '100%',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,
...Shadows.primary,
},
subscribeButtonTextFull: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
securityBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
marginTop: Spacing.lg,
},
securityText: {
fontSize: FontSizes.xs,
color: AppColors.success,
},
// Compact version styles
compactContainer: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
...Shadows.sm,
},
compactHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: Spacing.md,
},
compactIconContainer: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: AppColors.accentLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
compactInfo: {
flex: 1,
},
compactTitle: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
compactPrice: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.bold,
color: AppColors.primary,
marginTop: 2,
},
subscribeButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
},
subscribeButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
buttonDisabled: {
opacity: 0.7,
},
});

View File

@ -1,9 +1,221 @@
import React, { useEffect, useRef } from 'react'; import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
import { View, Text, StyleSheet, Animated } from 'react-native'; import {
View,
Text,
StyleSheet,
Animated,
TouchableOpacity,
Dimensions,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { AppColors, BorderRadius, FontSizes, Spacing, FontWeights, Shadows } from '@/constants/theme'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
interface ToastProps { const { width: SCREEN_WIDTH } = Dimensions.get('window');
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface ToastConfig {
type: ToastType;
title: string;
message?: string;
duration?: number;
action?: {
label: string;
onPress: () => void;
};
}
interface ToastContextValue {
show: (config: ToastConfig) => void;
success: (title: string, message?: string) => void;
error: (title: string, message?: string) => void;
info: (title: string, message?: string) => void;
hide: () => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
const toastConfig = {
success: {
icon: 'checkmark-circle' as const,
color: AppColors.success,
bgColor: AppColors.successLight,
},
error: {
icon: 'close-circle' as const,
color: AppColors.error,
bgColor: AppColors.errorLight,
},
info: {
icon: 'information-circle' as const,
color: AppColors.info,
bgColor: AppColors.infoLight,
},
warning: {
icon: 'warning' as const,
color: AppColors.warning,
bgColor: AppColors.warningLight,
},
};
interface ToastProviderProps {
children: React.ReactNode;
}
export function ToastProvider({ children }: ToastProviderProps) {
const insets = useSafeAreaInsets();
const [visible, setVisible] = useState(false);
const [config, setConfig] = useState<ToastConfig | null>(null);
const translateY = useRef(new Animated.Value(-100)).current;
const opacity = useRef(new Animated.Value(0)).current;
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const hide = useCallback(() => {
Animated.parallel([
Animated.timing(translateY, {
toValue: -100,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
]).start(() => {
setVisible(false);
setConfig(null);
});
}, [translateY, opacity]);
const show = useCallback((newConfig: ToastConfig) => {
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setConfig(newConfig);
setVisible(true);
// Animate in
Animated.parallel([
Animated.spring(translateY, {
toValue: 0,
tension: 80,
friction: 10,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
// Auto hide
const duration = newConfig.duration ?? 3000;
timeoutRef.current = setTimeout(() => {
hide();
}, duration);
}, [translateY, opacity, hide]);
const success = useCallback((title: string, message?: string) => {
show({ type: 'success', title, message });
}, [show]);
const error = useCallback((title: string, message?: string) => {
show({ type: 'error', title, message });
}, [show]);
const info = useCallback((title: string, message?: string) => {
show({ type: 'info', title, message });
}, [show]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const typeConfig = config ? toastConfig[config.type] : toastConfig.info;
return (
<ToastContext.Provider value={{ show, success, error, info, hide }}>
{children}
{visible && config && (
<Animated.View
style={[
styles.container,
{
top: insets.top + Spacing.sm,
transform: [{ translateY }],
opacity,
},
]}
pointerEvents="box-none"
>
<View style={styles.toast}>
{/* Icon */}
<View style={[styles.iconContainer, { backgroundColor: typeConfig.bgColor }]}>
<Ionicons
name={typeConfig.icon}
size={24}
color={typeConfig.color}
/>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.title}>{config.title}</Text>
{config.message && (
<Text style={styles.message} numberOfLines={2}>{config.message}</Text>
)}
</View>
{/* Action or Close */}
{config.action ? (
<TouchableOpacity
style={styles.actionButton}
onPress={() => {
config.action?.onPress();
hide();
}}
>
<Text style={styles.actionText}>{config.action.label}</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.closeButton} onPress={hide}>
<Ionicons name="close" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
)}
</View>
</Animated.View>
)}
</ToastContext.Provider>
);
}
// Legacy Toast component for backwards compatibility
interface LegacyToastProps {
visible: boolean; visible: boolean;
message: string; message: string;
icon?: keyof typeof Ionicons.glyphMap; icon?: keyof typeof Ionicons.glyphMap;
@ -11,7 +223,7 @@ interface ToastProps {
onHide: () => void; onHide: () => void;
} }
export function Toast({ visible, message, icon = 'checkmark-circle', duration = 2000, onHide }: ToastProps) { export function Toast({ visible, message, icon = 'checkmark-circle', duration = 2000, onHide }: LegacyToastProps) {
const fadeAnim = useRef(new Animated.Value(0)).current; const fadeAnim = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(20)).current; const translateY = useRef(new Animated.Value(20)).current;
@ -54,23 +266,81 @@ export function Toast({ visible, message, icon = 'checkmark-circle', duration =
return ( return (
<Animated.View <Animated.View
style={[ style={[
styles.container, styles.legacyContainer,
{ {
opacity: fadeAnim, opacity: fadeAnim,
transform: [{ translateY }], transform: [{ translateY }],
}, },
]} ]}
> >
<View style={styles.iconContainer}> <View style={styles.legacyIconContainer}>
<Ionicons name={icon} size={20} color={AppColors.white} /> <Ionicons name={icon} size={20} color={AppColors.white} />
</View> </View>
<Text style={styles.message}>{message}</Text> <Text style={styles.legacyMessage}>{message}</Text>
</Animated.View> </Animated.View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
// New Toast Provider styles
container: { container: {
position: 'absolute',
left: Spacing.md,
right: Spacing.md,
zIndex: 9999,
},
toast: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.md,
gap: Spacing.md,
...Shadows.lg,
borderWidth: 1,
borderColor: AppColors.borderLight,
},
iconContainer: {
width: 44,
height: 44,
borderRadius: BorderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
},
content: {
flex: 1,
},
title: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
message: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginTop: 2,
},
actionButton: {
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.primaryLighter,
borderRadius: BorderRadius.md,
},
actionText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.primary,
},
closeButton: {
width: 32,
height: 32,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
},
// Legacy Toast styles
legacyContainer: {
position: 'absolute', position: 'absolute',
bottom: 100, bottom: 100,
left: Spacing.xl, left: Spacing.xl,
@ -84,7 +354,7 @@ const styles = StyleSheet.create({
gap: Spacing.sm, gap: Spacing.sm,
...Shadows.lg, ...Shadows.lg,
}, },
iconContainer: { legacyIconContainer: {
width: 28, width: 28,
height: 28, height: 28,
borderRadius: 14, borderRadius: 14,
@ -92,7 +362,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
message: { legacyMessage: {
flex: 1, flex: 1,
fontSize: FontSizes.base, fontSize: FontSizes.base,
fontWeight: FontWeights.medium, fontWeight: FontWeights.medium,

View File

@ -50,6 +50,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const checkAuth = async () => { const checkAuth = async () => {
try { try {
console.log(`[AuthContext] checkAuth: Checking token...`); console.log(`[AuthContext] checkAuth: Checking token...`);
const token = await api.getToken();
console.log(`[AuthContext] checkAuth: Token exists=${!!token}, length=${token?.length || 0}`);
const isAuth = await api.isAuthenticated(); const isAuth = await api.isAuthenticated();
console.log(`[AuthContext] checkAuth: isAuth=${isAuth}`); console.log(`[AuthContext] checkAuth: isAuth=${isAuth}`);

12
package-lock.json generated
View File

@ -19,6 +19,7 @@
"expo-av": "~16.0.8", "expo-av": "~16.0.8",
"expo-build-properties": "~1.0.10", "expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.12", "expo-constants": "~18.0.12",
"expo-crypto": "~15.0.8", "expo-crypto": "~15.0.8",
"expo-font": "~14.0.10", "expo-font": "~14.0.10",
@ -10670,6 +10671,17 @@
} }
} }
}, },
"node_modules/expo-clipboard": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": { "node_modules/expo-constants": {
"version": "18.0.12", "version": "18.0.12",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz",

View File

@ -22,6 +22,7 @@
"expo-av": "~16.0.8", "expo-av": "~16.0.8",
"expo-build-properties": "~1.0.10", "expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.12", "expo-constants": "~18.0.12",
"expo-crypto": "~15.0.8", "expo-crypto": "~15.0.8",
"expo-font": "~14.0.10", "expo-font": "~14.0.10",

View File

@ -494,6 +494,165 @@ class ApiService {
deployment_id: deploymentId, deployment_id: deploymentId,
}); });
} }
// ==================== Invitations API ====================
// Send invitation to share access to a beneficiary
async sendInvitation(params: {
beneficiaryId: string;
email: string;
role: 'caretaker' | 'guardian';
label?: string;
}): Promise<ApiResponse<{ invitation: { id: string; status: string } }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/invitations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
beneficiaryId: params.beneficiaryId,
email: params.email,
role: params.role, // Backend expects 'caretaker' or 'guardian'
label: params.label,
}),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to send invitation' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Get invitations for a beneficiary
async getInvitations(beneficiaryId: string): Promise<ApiResponse<{
invitations: Array<{
id: string;
email: string;
role: string;
label?: string;
status: string;
created_at: string;
}>
}>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/invitations/beneficiary/${beneficiaryId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to get invitations' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Update invitation role
async updateInvitation(invitationId: string, role: 'caretaker' | 'guardian'): Promise<ApiResponse<{ success: boolean }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/invitations/${invitationId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
role: role === 'guardian' ? 'owner' : 'viewer',
}),
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to update invitation' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Delete invitation
async deleteInvitation(invitationId: string): Promise<ApiResponse<{ success: boolean }>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
const response = await fetch(`${WELLNUO_API_URL}/invitations/${invitationId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to delete invitation' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
} }
export const api = new ApiService(); export const api = new ApiService();