Update subscription, equipment screens and auth flow
This commit is contained in:
parent
2545aec485
commit
b869e9e3ab
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
609
app/(tabs)/beneficiaries/[id]/equipment.tsx
Normal file
609
app/(tabs)/beneficiaries/[id]/equipment.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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: {
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
414
components/SubscriptionPayment.tsx
Normal file
414
components/SubscriptionPayment.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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
12
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
159
services/api.ts
159
services/api.ts
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user