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,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export default function LoginScreen() {
const { checkEmail, requestOtp, isLoading, error, clearError } = useAuth();
const [email, setEmail] = useState('');
const [partnerCode, setPartnerCode] = useState('');
const [showPartnerCode, setShowPartnerCode] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
// Clear errors on mount
@ -140,22 +143,33 @@ export default function LoginScreen() {
returnKeyType="next"
/>
{/* Partner Code Toggle */}
{!showPartnerCode ? (
<TouchableOpacity
style={styles.partnerCodeToggle}
onPress={() => setShowPartnerCode(true)}
>
<Ionicons name="gift-outline" size={18} color={AppColors.primary} />
<Text style={styles.partnerCodeToggleText}>I have a partner code</Text>
</TouchableOpacity>
) : (
<Input
label="Partner Code (optional)"
placeholder="6-digit code from a friend"
leftIcon="people-outline"
label="Partner Code"
placeholder="Enter 5-digit code"
leftIcon="gift-outline"
value={partnerCode}
onChangeText={(text) => {
// Only allow digits, max 6
const digits = text.replace(/\D/g, '').slice(0, 6);
setPartnerCode(digits);
// Only allow digits, max 5
const code = text.replace(/\D/g, '').slice(0, 5);
setPartnerCode(code);
}}
keyboardType="number-pad"
maxLength={6}
maxLength={5}
editable={!isLoading}
onSubmitEditing={handleContinue}
returnKeyType="done"
/>
)}
<Button
title="Continue"
@ -236,4 +250,17 @@ const styles = StyleSheet.create({
color: AppColors.textMuted,
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 { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native';
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity, Modal, TextInput, Image, ScrollView, KeyboardAvoidingView, Platform, Alert, Animated } from 'react-native';
import { WebView } from 'react-native-webview';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, router } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import * as ImagePicker from 'expo-image-picker';
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 { useToast } from '@/components/ui/Toast';
import MockDashboard from '@/components/MockDashboard';
import { SubscriptionPayment } from '@/components/SubscriptionPayment';
import type { Beneficiary } from '@/types';
// Dashboard URL with beneficiary ID (deployment_id)
const getDashboardUrl = (deploymentId: string) =>
@ -23,7 +28,8 @@ const isLocalBeneficiary = (id: string | number): boolean => {
export default function BeneficiaryDashboardScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary, setCurrentBeneficiary } = useBeneficiary();
const { currentBeneficiary, setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary } = useBeneficiary();
const toast = useToast();
const webViewRef = useRef<WebView>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -32,10 +38,128 @@ export default function BeneficiaryDashboardScreen() {
const [userName, setUserName] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
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
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
const dashboardUrl = id ? getDashboardUrl(id) : 'https://react.eluxnetworks.net/dashboard';
@ -108,8 +232,8 @@ export default function BeneficiaryDashboardScreen() {
router.back();
};
// Wait for token to load before showing WebView (skip for local beneficiaries)
if (!isTokenLoaded && !isLocal) {
// Wait for beneficiary data and token to load
if (isBeneficiaryLoading || (!isTokenLoaded && !isLocal)) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<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) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
@ -178,9 +322,61 @@ export default function BeneficiaryDashboardScreen() {
<Ionicons name="refresh" size={22} color={AppColors.primary} />
</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>
{/* Backdrop to close menu */}
{isMenuVisible && (
<TouchableOpacity
style={styles.menuBackdrop}
activeOpacity={1}
onPress={() => setIsMenuVisible(false)}
/>
)}
{/* Dashboard Content - Mock for local, WebView for real */}
{isLocal ? (
<MockDashboard beneficiaryName={beneficiaryName} />
@ -220,45 +416,98 @@ export default function BeneficiaryDashboardScreen() {
</View>
)}
{/* Bottom Quick Actions */}
<View style={styles.bottomBar}>
<TouchableOpacity
style={styles.quickAction}
onPress={() => {
if (currentBeneficiary) {
setCurrentBeneficiary(currentBeneficiary);
}
router.push('/(tabs)/chat');
}}
{/* Edit Modal */}
<Modal
visible={isEditModalVisible}
transparent
animationType="none"
onRequestClose={() => setIsEditModalVisible(false)}
>
<Ionicons name="chatbubble-ellipses" size={22} color={AppColors.primary} />
<Text style={styles.quickActionText}>Chat</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.quickAction}
onPress={() => router.push(`./edit`)}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<Ionicons name="create-outline" size={22} color={AppColors.primary} />
<Text style={styles.quickActionText}>Edit</Text>
</TouchableOpacity>
<Animated.View style={[styles.modalOverlay, { opacity: fadeAnim }]}>
<TouchableOpacity
style={styles.quickAction}
onPress={() => router.push(`/(tabs)/profile/subscription`)}
>
<Ionicons name="card-outline" size={22} color={AppColors.primary} />
<Text style={styles.quickActionText}>Subscribe</Text>
</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.quickAction}
onPress={() => router.push(`./share`)}
style={styles.modalCloseButton}
onPress={() => setIsEditModalVisible(false)}
>
<Ionicons name="share-outline" size={22} color={AppColors.primary} />
<Text style={styles.quickActionText}>Share</Text>
<Ionicons name="close" size={24} color={AppColors.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Avatar Picker */}
<TouchableOpacity style={styles.avatarPicker} onPress={handlePickAvatar}>
{editForm.avatar ? (
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
) : (
<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>
{/* Name Field */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Name *</Text>
<TextInput
style={styles.textInput}
value={editForm.name}
onChangeText={(text) => setEditForm(prev => ({ ...prev, name: text }))}
placeholder="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>
</SafeAreaView>
);
}
@ -276,6 +525,7 @@ const styles = StyleSheet.create({
backgroundColor: AppColors.background,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
zIndex: 1001,
},
backButton: {
padding: Spacing.xs,
@ -312,14 +562,53 @@ const styles = StyleSheet.create({
headerActions: {
flexDirection: 'row',
alignItems: 'center',
position: 'relative',
},
actionButton: {
padding: Spacing.xs,
marginLeft: Spacing.xs,
},
menuButton: {
padding: Spacing.xs,
marginLeft: Spacing.sm,
},
placeholder: {
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: {
flex: 1,
},
@ -351,22 +640,202 @@ const styles = StyleSheet.create({
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
bottomBar: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
backgroundColor: AppColors.background,
borderTopWidth: 1,
borderTopColor: AppColors.border,
},
quickAction: {
// No Subscription Styles
noSubscriptionContainer: {
flex: 1,
padding: Spacing.xl,
alignItems: 'center',
padding: Spacing.sm,
justifyContent: 'center',
},
quickActionText: {
fontSize: FontSizes.xs,
noSubIconContainer: {
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,
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,
Platform,
Animated,
ActivityIndicator,
} from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@ -23,6 +24,9 @@ import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { FullScreenError } from '@/components/ui/ErrorMessage';
import { Button } from '@/components/ui/Button';
import { SubscriptionPayment } from '@/components/SubscriptionPayment';
import { useToast } from '@/components/ui/Toast';
import MockDashboard from '@/components/MockDashboard';
import {
AppColors,
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
const equipmentStatusInfo = {
@ -292,6 +229,7 @@ function AwaitingEquipmentScreen({
export default function BeneficiaryDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary, removeLocalBeneficiary } = useBeneficiary();
const toast = useToast();
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
@ -327,6 +265,9 @@ export default function BeneficiaryDetailScreen() {
return 'ready';
}, [beneficiary, isLoading]);
// Dropdown menu state
const [isMenuVisible, setIsMenuVisible] = useState(false);
// Edit modal state
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [editForm, setEditForm] = useState({
@ -399,20 +340,12 @@ export default function BeneficiaryDetailScreen() {
const handleGetSensors = () => {
// For now, show info or redirect to purchase
Alert.alert(
toast.info(
'Get WellNuo Sensors',
'WellNuo sensor kits include motion sensors, door sensors, and temperature monitors.\n\nVisit wellnuo.com to order.',
[{ text: 'OK' }]
'WellNuo sensor kits include motion sensors, door sensors, and temperature monitors. Visit wellnuo.com to order.'
);
};
const handleSubscribe = () => {
router.push({
pathname: '/(tabs)/beneficiaries/[id]/purchase',
params: { id: id! },
});
};
const handleMarkReceived = async () => {
if (!beneficiary || !id) return;
@ -427,7 +360,7 @@ export default function BeneficiaryDetailScreen() {
}
// For API beneficiaries, would call backend here
} 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 { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
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;
}
@ -470,7 +403,7 @@ export default function BeneficiaryDetailScreen() {
const handleSaveEdit = async () => {
if (!editForm.name.trim()) {
Alert.alert('Error', 'Name is required');
toast.error('Error', 'Name is required');
return;
}
@ -484,24 +417,18 @@ export default function BeneficiaryDetailScreen() {
if (updated) {
setBeneficiary(updated);
setIsEditModalVisible(false);
toast.success('Saved', 'Profile updated successfully');
} else {
Alert.alert('Error', 'Failed to save changes.');
toast.error('Error', 'Failed to save changes.');
}
} else {
Alert.alert(
'Demo Mode',
'Saving beneficiary data requires backend API.',
[{ text: 'OK', onPress: () => setIsEditModalVisible(false) }]
);
toast.info('Demo Mode', 'Saving beneficiary data requires backend API.');
setIsEditModalVisible(false);
}
};
const showComingSoon = (featureName: string) => {
Alert.alert(
'Coming Soon',
`${featureName} is currently in development.`,
[{ text: 'OK' }]
);
toast.info('Coming Soon', `${featureName} is currently in development.`);
};
const handleDeleteBeneficiary = () => {
@ -520,7 +447,7 @@ export default function BeneficiaryDetailScreen() {
await removeLocalBeneficiary(parseInt(id, 10));
router.replace('/(tabs)');
} 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':
return (
<NoSubscriptionScreen
<SubscriptionPayment
beneficiary={beneficiary}
onSubscribe={handleSubscribe}
onSuccess={() => loadBeneficiary(false)}
/>
);
@ -628,16 +555,6 @@ export default function BeneficiaryDetailScreen() {
{/* Quick Actions */}
<Text style={styles.sectionTitle}>Quick Actions</Text>
<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
style={styles.actionButton}
onPress={() => {
@ -648,7 +565,17 @@ export default function BeneficiaryDetailScreen() {
<View style={[styles.actionButtonIcon, { backgroundColor: AppColors.accentLight }]}>
<Ionicons name="chatbubbles" size={22} color={AppColors.accent} />
</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>
</View>
@ -745,6 +672,9 @@ export default function BeneficiaryDetailScreen() {
</TouchableOpacity>
</>
)}
{/* Activity Dashboard */}
<MockDashboard beneficiaryName={beneficiary.name} />
</ScrollView>
);
}
@ -758,10 +688,106 @@ export default function BeneficiaryDetailScreen() {
<Ionicons name="arrow-back" size={22} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
<TouchableOpacity style={styles.headerButton} onPress={handleEditPress}>
<Ionicons name="create-outline" size={22} color={AppColors.primary} />
<View>
<TouchableOpacity style={styles.headerButton} onPress={() => setIsMenuVisible(!isMenuVisible)}>
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
</TouchableOpacity>
{/* Dropdown Menu */}
{isMenuVisible && (
<View style={styles.dropdownMenu}>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
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>
{/* Backdrop to close menu */}
{isMenuVisible && (
<TouchableOpacity
style={styles.menuBackdrop}
activeOpacity={1}
onPress={() => setIsMenuVisible(false)}
/>
)}
{renderContent()}
@ -885,6 +911,7 @@ const styles = StyleSheet.create({
paddingVertical: Spacing.md,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
zIndex: 1001,
},
headerButton: {
width: 40,
@ -1537,4 +1564,37 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// Dropdown Menu
dropdownMenu: {
position: 'absolute',
top: 44,
right: 0,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
minWidth: 160,
...Shadows.lg,
zIndex: 1000,
},
dropdownItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.lg,
gap: Spacing.md,
},
dropdownItemText: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
dropdownItemDanger: {
borderTopWidth: 1,
borderTopColor: AppColors.borderLight,
},
dropdownItemTextDanger: {
color: AppColors.error,
},
menuBackdrop: {
...StyleSheet.absoluteFillObject,
zIndex: 999,
},
});

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
@ -10,32 +10,75 @@ import {
ScrollView,
KeyboardAvoidingView,
Platform,
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 { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
type Role = 'caretaker' | 'guardian';
interface Invitation {
id: string;
email: string;
role: string;
label?: string;
status: string;
created_at: string;
}
export default function ShareAccessScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary } = useBeneficiary();
const { user } = useAuth();
const [email, setEmail] = useState('');
const [label, setLabel] = useState('');
const [role, setRole] = useState<Role>('caretaker');
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 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 emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@ -44,35 +87,47 @@ export default function ShareAccessScreen() {
const handleSendInvite = async () => {
const trimmedEmail = email.trim().toLowerCase();
const trimmedLabel = label.trim();
if (!trimmedEmail) {
Alert.alert('Email Required', 'Please enter an email address.');
Alert.alert('Error', 'Please enter an email address.');
return;
}
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;
}
setIsLoading(true);
try {
// TODO: Send invitation via API
// await api.sendInvitation({
// beneficiaryId: id,
// email: trimmedEmail,
// label: trimmedLabel,
// role: role,
// });
const response = await api.sendInvitation({
beneficiaryId: id!,
email: trimmedEmail,
role: role,
});
// For now, show success message
Alert.alert(
'Invitation Sent',
`An invitation has been sent to ${trimmedEmail} to ${role === 'guardian' ? 'manage' : 'view'} ${beneficiaryName}.`,
[{ text: 'OK', onPress: () => router.back() }]
);
if (response.ok) {
setEmail('');
setRole('caretaker');
loadInvitations();
Alert.alert('Success', `Invitation sent to ${trimmedEmail}`);
} else {
Alert.alert('Error', response.error?.message || 'Failed to send invitation');
}
} catch (error) {
console.error('Failed to send invitation:', error);
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 (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
@ -88,7 +183,7 @@ export default function ShareAccessScreen() {
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Share Access</Text>
<Text style={styles.headerTitle}>Access</Text>
<View style={styles.placeholder} />
</View>
@ -99,23 +194,18 @@ export default function ShareAccessScreen() {
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{/* Info Banner */}
<View style={styles.infoBanner}>
<Ionicons name="people" size={24} color={AppColors.primary} />
<Text style={styles.infoBannerText}>
Invite family members or caregivers to help monitor {beneficiaryName}
</Text>
</View>
{/* Invite Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Invite Someone</Text>
{/* Email Input */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Email Address</Text>
<View style={styles.inputContainer}>
<Ionicons name="mail-outline" size={20} color={AppColors.textMuted} />
<View style={styles.inputRow}>
<TextInput
style={styles.input}
placeholder="Enter email address"
placeholder="Email address"
placeholderTextColor={AppColors.textMuted}
value={email}
onChangeText={setEmail}
@ -124,105 +214,95 @@ export default function ShareAccessScreen() {
keyboardType="email-address"
editable={!isLoading}
/>
</View>
</View>
{/* Label Input */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Label (optional)</Text>
<View style={styles.inputContainer}>
<Ionicons name="pricetag-outline" size={20} color={AppColors.textMuted} />
<TextInput
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
style={[styles.roleOption, role === 'guardian' && styles.roleOptionSelected]}
onPress={() => setRole('guardian')}
disabled={isLoading}
>
<View style={styles.roleHeader}>
<View style={[styles.roleIcon, role === 'guardian' && styles.roleIconSelected]}>
<Ionicons
name="shield-outline"
size={20}
color={role === 'guardian' ? AppColors.white : AppColors.accent}
/>
</View>
<View style={styles.roleInfo}>
<Text style={[styles.roleTitle, role === 'guardian' && styles.roleTitleSelected]}>
Guardian
</Text>
<Text style={styles.roleDescription}>
Full access: edit info, manage subscription, invite others
</Text>
</View>
{role === 'guardian' && (
<Ionicons name="checkmark-circle" size={24} color={AppColors.accent} />
)}
</View>
</TouchableOpacity>
</View>
{/* Send Button */}
<TouchableOpacity
style={[styles.sendButton, isLoading && styles.sendButtonDisabled]}
onPress={handleSendInvite}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={AppColors.white} />
<ActivityIndicator size="small" color={AppColors.white} />
) : (
<>
<Ionicons name="send" size={20} color={AppColors.white} />
<Text style={styles.sendButtonText}>Send Invitation</Text>
</>
<Ionicons name="send" size={18} color={AppColors.white} />
)}
</TouchableOpacity>
</View>
{/* Help Text */}
<Text style={styles.helpText}>
The person will receive an email with instructions to access {beneficiaryName}'s information.
{/* Role Toggle */}
<View style={styles.roleToggle}>
<TouchableOpacity
style={[styles.roleButton, role === 'caretaker' && styles.roleButtonActive]}
onPress={() => setRole('caretaker')}
>
<Text style={[styles.roleButtonText, role === 'caretaker' && styles.roleButtonTextActive]}>
Caretaker
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.roleButton, role === 'guardian' && styles.roleButtonActive]}
onPress={() => setRole('guardian')}
>
<Text style={[styles.roleButtonText, role === 'guardian' && styles.roleButtonTextActive]}>
Guardian
</Text>
</TouchableOpacity>
</View>
<Text style={styles.roleHint}>
{role === 'caretaker'
? 'Can view activity and chat with Julia'
: 'Full access: edit info, manage subscription'}
</Text>
</View>
{/* People with Access */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>People with Access</Text>
{isLoadingInvitations ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={AppColors.primary} />
</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}>
{invitations.map((invitation) => (
<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>
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
@ -261,114 +341,148 @@ const styles = StyleSheet.create({
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
infoBanner: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primaryLighter,
padding: Spacing.md,
borderRadius: BorderRadius.lg,
section: {
marginBottom: Spacing.xl,
gap: Spacing.md,
},
infoBannerText: {
flex: 1,
sectionTitle: {
fontSize: FontSizes.sm,
color: AppColors.textPrimary,
lineHeight: 20,
},
inputGroup: {
marginBottom: Spacing.lg,
},
inputLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
marginBottom: Spacing.sm,
marginBottom: Spacing.md,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
inputContainer: {
inputRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
paddingHorizontal: Spacing.md,
borderWidth: 1,
borderColor: AppColors.border,
gap: Spacing.sm,
},
input: {
flex: 1,
paddingVertical: Spacing.md,
marginLeft: Spacing.sm,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
roleOption: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.sm,
borderWidth: 2,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
borderWidth: 1,
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: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
width: 48,
height: 48,
borderRadius: BorderRadius.lg,
marginTop: Spacing.lg,
gap: Spacing.sm,
...Shadows.primary,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
},
sendButtonDisabled: {
opacity: 0.7,
},
sendButtonText: {
fontSize: FontSizes.base,
roleToggle: {
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,
color: AppColors.white,
},
helpText: {
invitationDetails: {
flex: 1,
},
invitationEmail: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
textAlign: 'center',
marginTop: Spacing.lg,
lineHeight: 20,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
},
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 {
View,
Text,
@ -10,13 +10,12 @@ import {
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { usePaymentSheet } from '@stripe/stripe-react-native';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { PageHeader } from '@/components/PageHeader';
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useAuth } from '@/contexts/AuthContext';
import { useSubscription } from '@/hooks/useSubscription';
import type { BeneficiarySubscription } from '@/types';
import type { Beneficiary, BeneficiarySubscription } from '@/types';
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const SUBSCRIPTION_PRICE = 49; // $49/month
@ -42,16 +41,44 @@ function PlanFeature({ text, included }: PlanFeatureProps) {
}
export default function SubscriptionScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [isProcessing, setIsProcessing] = useState(false);
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const { user } = useAuth();
const { currentBeneficiary, updateLocalBeneficiary } = useBeneficiary();
const { isActive, daysRemaining, subscription, beneficiaryName } = useSubscription();
const { getBeneficiaryById, updateLocalBeneficiary } = useBeneficiary();
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 () => {
if (!currentBeneficiary) {
Alert.alert('No Beneficiary', 'Please select a loved one first.');
if (!beneficiary) {
Alert.alert('Error', 'Beneficiary data not loaded.');
return;
}
@ -71,8 +98,8 @@ export default function SubscriptionScreen() {
type: 'subscription',
planType: 'monthly',
userId: user?.user_id || 'guest',
beneficiaryId: currentBeneficiary.id,
beneficiaryName: currentBeneficiary.name,
beneficiaryId: beneficiary.id,
beneficiaryName: beneficiary.name,
},
}),
});
@ -130,13 +157,16 @@ export default function SubscriptionScreen() {
price: SUBSCRIPTION_PRICE,
};
await updateLocalBeneficiary(currentBeneficiary.id, {
await updateLocalBeneficiary(beneficiary.id, {
subscription: newSubscription,
});
// Reload beneficiary to get updated subscription
await loadBeneficiary();
Alert.alert(
'Subscription Activated!',
`Subscription for ${currentBeneficiary.name} is now active until ${formatDate(endDate)}.`,
`Subscription for ${beneficiary.name} is now active until ${formatDate(endDate)}.`,
[{ text: 'Great!' }]
);
} catch (error) {
@ -170,18 +200,40 @@ export default function SubscriptionScreen() {
});
};
// No beneficiary selected
if (!currentBeneficiary) {
if (isLoading) {
return (
<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.noBeneficiaryIcon}>
<Ionicons name="person-outline" size={48} color={AppColors.textMuted} />
</View>
<Text style={styles.noBeneficiaryTitle}>No Loved One Selected</Text>
<Text style={styles.noBeneficiaryTitle}>Beneficiary Not Found</Text>
<Text style={styles.noBeneficiaryText}>
Please select a loved one from the home screen to manage their subscription.
Unable to load beneficiary information.
</Text>
</View>
</SafeAreaView>
@ -190,18 +242,26 @@ export default function SubscriptionScreen() {
return (
<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}>
{/* Beneficiary Info */}
<View style={styles.beneficiaryBanner}>
<View style={styles.beneficiaryAvatar}>
<Text style={styles.beneficiaryAvatarText}>
{currentBeneficiary.name.charAt(0).toUpperCase()}
{beneficiary.name.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.beneficiaryInfo}>
<Text style={styles.beneficiaryLabel}>Subscription for</Text>
<Text style={styles.beneficiaryName}>{currentBeneficiary.name}</Text>
<Text style={styles.beneficiaryName}>{beneficiary.name}</Text>
</View>
</View>
@ -233,7 +293,7 @@ export default function SubscriptionScreen() {
<Text style={styles.statusTitle}>
{subscription?.status === 'expired'
? 'Subscription has expired'
: `Subscribe for ${beneficiaryName}`}
: `Subscribe for ${beneficiary.name}`}
</Text>
<Text style={styles.statusDescription}>
Get full access to monitoring features
@ -313,6 +373,31 @@ const styles = StyleSheet.create({
flex: 1,
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
noBeneficiaryContainer: {
flex: 1,
@ -331,7 +416,7 @@ const styles = StyleSheet.create({
},
noBeneficiaryTitle: {
fontSize: FontSizes.xl,
fontWeight: '700',
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.sm,
},
@ -361,7 +446,7 @@ const styles = StyleSheet.create({
},
beneficiaryAvatarText: {
fontSize: FontSizes.xl,
fontWeight: '700',
fontWeight: FontWeights.bold,
color: AppColors.white,
},
beneficiaryInfo: {
@ -373,7 +458,7 @@ const styles = StyleSheet.create({
},
beneficiaryName: {
fontSize: FontSizes.lg,
fontWeight: '700',
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
// Status banner
@ -393,7 +478,7 @@ const styles = StyleSheet.create({
},
activeBadgeText: {
fontSize: FontSizes.sm,
fontWeight: '700',
fontWeight: FontWeights.bold,
color: AppColors.success,
},
inactiveBadge: {
@ -407,12 +492,12 @@ const styles = StyleSheet.create({
},
inactiveBadgeText: {
fontSize: FontSizes.sm,
fontWeight: '700',
fontWeight: FontWeights.bold,
color: AppColors.error,
},
statusTitle: {
fontSize: FontSizes.xl,
fontWeight: '700',
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginTop: Spacing.md,
textAlign: 'center',
@ -429,7 +514,7 @@ const styles = StyleSheet.create({
},
daysRemainingNumber: {
fontSize: 48,
fontWeight: '700',
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
daysRemainingLabel: {
@ -459,7 +544,7 @@ const styles = StyleSheet.create({
},
proBadgeText: {
fontSize: FontSizes.lg,
fontWeight: '700',
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
priceContainer: {
@ -469,7 +554,7 @@ const styles = StyleSheet.create({
},
priceAmount: {
fontSize: 48,
fontWeight: '700',
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
pricePeriod: {
@ -509,7 +594,7 @@ const styles = StyleSheet.create({
},
subscribeButtonText: {
fontSize: FontSizes.base,
fontWeight: '600',
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
securityBadge: {

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
View,
Text,
@ -12,6 +12,7 @@ import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as SecureStore from 'expo-secure-store';
import * as ImagePicker from 'expo-image-picker';
import * as Clipboard from 'expo-clipboard';
import { router } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext';
import { ProfileDrawer } from '@/components/ProfileDrawer';
@ -25,6 +26,20 @@ import {
AvatarSizes,
} 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() {
const { user, logout } = useAuth();
@ -106,6 +121,17 @@ export default function ProfileScreen() {
const userName = user?.user_name || 'User';
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 (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
@ -142,6 +168,19 @@ export default function ProfileScreen() {
<Text style={styles.userName}>{userName}</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>
{/* Menu Items */}
@ -150,8 +189,8 @@ export default function ProfileScreen() {
style={styles.menuItem}
onPress={() => router.push('/(tabs)/profile/edit')}
>
<View style={[styles.menuIcon, { backgroundColor: AppColors.primarySubtle }]}>
<Ionicons name="person-outline" size={22} color={AppColors.primary} />
<View style={styles.menuIcon}>
<Ionicons name="person-outline" size={22} color={AppColors.textSecondary} />
</View>
<Text style={styles.menuLabel}>Edit Profile</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
@ -161,8 +200,8 @@ export default function ProfileScreen() {
style={styles.menuItem}
onPress={() => router.push('/(tabs)')}
>
<View style={[styles.menuIcon, { backgroundColor: AppColors.accentLight }]}>
<Ionicons name="people-outline" size={22} color={AppColors.accent} />
<View style={styles.menuIcon}>
<Ionicons name="people-outline" size={22} color={AppColors.textSecondary} />
</View>
<Text style={styles.menuLabel}>My Loved Ones</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
@ -172,21 +211,10 @@ export default function ProfileScreen() {
style={styles.menuItem}
onPress={() => setDrawerVisible(true)}
>
<View style={[styles.menuIcon, { backgroundColor: AppColors.surfaceSecondary }]}>
<View style={styles.menuIcon}>
<Ionicons name="settings-outline" size={22} color={AppColors.textSecondary} />
</View>
<Text style={styles.menuLabel}>App 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>
<Text style={styles.menuLabel}>Settings</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
@ -194,7 +222,7 @@ export default function ProfileScreen() {
style={[styles.menuItem, styles.menuItemLast]}
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} />
</View>
<Text style={[styles.menuLabel, { color: AppColors.error }]}>Log Out</Text>
@ -306,6 +334,46 @@ const styles = StyleSheet.create({
fontSize: FontSizes.sm,
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
menuSection: {
backgroundColor: AppColors.surface,
@ -328,10 +396,14 @@ const styles = StyleSheet.create({
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
menuIconDanger: {
backgroundColor: AppColors.errorLight,
},
menuLabel: {
flex: 1,
fontSize: FontSizes.base,

View File

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

View File

@ -46,7 +46,7 @@ function DrawerItem({ icon, label, onPress, rightElement, danger, badge }: Drawe
<Ionicons
name={icon}
size={20}
color={danger ? AppColors.error : AppColors.primary}
color={danger ? AppColors.error : AppColors.textSecondary}
/>
</View>
<Text style={[styles.drawerItemLabel, danger && styles.dangerText]}>
@ -150,7 +150,7 @@ export function ProfileDrawer({
<Text style={styles.sectionTitle}>Preferences</Text>
<View style={styles.sectionCard}>
<DrawerItem
icon="notifications"
icon="notifications-outline"
label="Push Notifications"
rightElement={
<Switch
@ -164,7 +164,7 @@ export function ProfileDrawer({
/>
<View style={styles.divider} />
<DrawerItem
icon="mail"
icon="mail-outline"
label="Email Notifications"
rightElement={
<Switch
@ -183,12 +183,6 @@ export function ProfileDrawer({
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text>
<View style={styles.sectionCard}>
<DrawerItem
icon="person-outline"
label="Edit Profile"
onPress={() => handleNavigate('/(tabs)/profile/edit')}
/>
<View style={styles.divider} />
<DrawerItem
icon="language-outline"
label="Language"
@ -234,13 +228,6 @@ export function ProfileDrawer({
</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>
{/* Version */}
@ -342,7 +329,7 @@ const styles = StyleSheet.create({
width: 36,
height: 36,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.primarySubtle,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: '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 { View, Text, StyleSheet, Animated } from 'react-native';
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Animated,
TouchableOpacity,
Dimensions,
} from 'react-native';
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;
message: string;
icon?: keyof typeof Ionicons.glyphMap;
@ -11,7 +223,7 @@ interface ToastProps {
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 translateY = useRef(new Animated.Value(20)).current;
@ -54,23 +266,81 @@ export function Toast({ visible, message, icon = 'checkmark-circle', duration =
return (
<Animated.View
style={[
styles.container,
styles.legacyContainer,
{
opacity: fadeAnim,
transform: [{ translateY }],
},
]}
>
<View style={styles.iconContainer}>
<View style={styles.legacyIconContainer}>
<Ionicons name={icon} size={20} color={AppColors.white} />
</View>
<Text style={styles.message}>{message}</Text>
<Text style={styles.legacyMessage}>{message}</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
// New Toast Provider styles
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',
bottom: 100,
left: Spacing.xl,
@ -84,7 +354,7 @@ const styles = StyleSheet.create({
gap: Spacing.sm,
...Shadows.lg,
},
iconContainer: {
legacyIconContainer: {
width: 28,
height: 28,
borderRadius: 14,
@ -92,7 +362,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
message: {
legacyMessage: {
flex: 1,
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,

View File

@ -50,6 +50,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const checkAuth = async () => {
try {
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();
console.log(`[AuthContext] checkAuth: isAuth=${isAuth}`);

12
package-lock.json generated
View File

@ -19,6 +19,7 @@
"expo-av": "~16.0.8",
"expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.12",
"expo-crypto": "~15.0.8",
"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": {
"version": "18.0.12",
"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-build-properties": "~1.0.10",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.12",
"expo-crypto": "~15.0.8",
"expo-font": "~14.0.10",

View File

@ -494,6 +494,165 @@ class ApiService {
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();