Update subscription, equipment screens and auth flow
This commit is contained in:
parent
2545aec485
commit
b869e9e3ab
@ -12,13 +12,16 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const { checkEmail, requestOtp, isLoading, error, clearError } = useAuth();
|
const { checkEmail, requestOtp, isLoading, error, clearError } = useAuth();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [partnerCode, setPartnerCode] = useState('');
|
const [partnerCode, setPartnerCode] = useState('');
|
||||||
|
const [showPartnerCode, setShowPartnerCode] = useState(false);
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Clear errors on mount
|
// Clear errors on mount
|
||||||
@ -140,22 +143,33 @@ export default function LoginScreen() {
|
|||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 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
|
<Input
|
||||||
label="Partner Code (optional)"
|
label="Partner Code"
|
||||||
placeholder="6-digit code from a friend"
|
placeholder="Enter 5-digit code"
|
||||||
leftIcon="people-outline"
|
leftIcon="gift-outline"
|
||||||
value={partnerCode}
|
value={partnerCode}
|
||||||
onChangeText={(text) => {
|
onChangeText={(text) => {
|
||||||
// Only allow digits, max 6
|
// Only allow digits, max 5
|
||||||
const digits = text.replace(/\D/g, '').slice(0, 6);
|
const code = text.replace(/\D/g, '').slice(0, 5);
|
||||||
setPartnerCode(digits);
|
setPartnerCode(code);
|
||||||
}}
|
}}
|
||||||
keyboardType="number-pad"
|
keyboardType="number-pad"
|
||||||
maxLength={6}
|
maxLength={5}
|
||||||
editable={!isLoading}
|
editable={!isLoading}
|
||||||
onSubmitEditing={handleContinue}
|
onSubmitEditing={handleContinue}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title="Continue"
|
title="Continue"
|
||||||
@ -236,4 +250,17 @@ const styles = StyleSheet.create({
|
|||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
marginTop: 'auto',
|
marginTop: 'auto',
|
||||||
},
|
},
|
||||||
|
partnerCodeToggle: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: Spacing.sm,
|
||||||
|
marginTop: Spacing.xs,
|
||||||
|
gap: Spacing.xs,
|
||||||
|
},
|
||||||
|
partnerCodeToggleText: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,14 +1,19 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity, Modal, TextInput, Image, ScrollView, KeyboardAvoidingView, Platform, Alert, Animated } from 'react-native';
|
||||||
import { WebView } from 'react-native-webview';
|
import { WebView } from 'react-native-webview';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
import { api } from '@/services/api';
|
||||||
|
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows, AvatarSizes } from '@/constants/theme';
|
||||||
import { FullScreenError } from '@/components/ui/ErrorMessage';
|
import { FullScreenError } from '@/components/ui/ErrorMessage';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import MockDashboard from '@/components/MockDashboard';
|
import MockDashboard from '@/components/MockDashboard';
|
||||||
|
import { SubscriptionPayment } from '@/components/SubscriptionPayment';
|
||||||
|
import type { Beneficiary } from '@/types';
|
||||||
|
|
||||||
// Dashboard URL with beneficiary ID (deployment_id)
|
// Dashboard URL with beneficiary ID (deployment_id)
|
||||||
const getDashboardUrl = (deploymentId: string) =>
|
const getDashboardUrl = (deploymentId: string) =>
|
||||||
@ -23,7 +28,8 @@ const isLocalBeneficiary = (id: string | number): boolean => {
|
|||||||
|
|
||||||
export default function BeneficiaryDashboardScreen() {
|
export default function BeneficiaryDashboardScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { currentBeneficiary, setCurrentBeneficiary } = useBeneficiary();
|
const { currentBeneficiary, setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary } = useBeneficiary();
|
||||||
|
const toast = useToast();
|
||||||
const webViewRef = useRef<WebView>(null);
|
const webViewRef = useRef<WebView>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -32,10 +38,128 @@ export default function BeneficiaryDashboardScreen() {
|
|||||||
const [userName, setUserName] = useState<string | null>(null);
|
const [userName, setUserName] = useState<string | null>(null);
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
const [isTokenLoaded, setIsTokenLoaded] = useState(false);
|
const [isTokenLoaded, setIsTokenLoaded] = useState(false);
|
||||||
|
const [isMenuVisible, setIsMenuVisible] = useState(false);
|
||||||
|
|
||||||
|
// Edit modal state
|
||||||
|
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
avatar: '' as string | undefined,
|
||||||
|
});
|
||||||
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
// Beneficiary data for subscription check
|
||||||
|
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
|
||||||
|
const [isBeneficiaryLoading, setIsBeneficiaryLoading] = useState(true);
|
||||||
|
|
||||||
// Check if this is a local (mock) beneficiary
|
// Check if this is a local (mock) beneficiary
|
||||||
const isLocal = useMemo(() => id ? isLocalBeneficiary(id) : false, [id]);
|
const isLocal = useMemo(() => id ? isLocalBeneficiary(id) : false, [id]);
|
||||||
|
|
||||||
|
// Check subscription status
|
||||||
|
const hasActiveSubscription = useMemo(() => {
|
||||||
|
if (!beneficiary) return false;
|
||||||
|
const subscription = beneficiary.subscription;
|
||||||
|
return subscription && subscription.status === 'active';
|
||||||
|
}, [beneficiary]);
|
||||||
|
|
||||||
|
// Load beneficiary data to check subscription
|
||||||
|
const loadBeneficiary = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
setIsBeneficiaryLoading(true);
|
||||||
|
try {
|
||||||
|
if (isLocal) {
|
||||||
|
const localBeneficiary = localBeneficiaries.find(
|
||||||
|
(b) => b.id === parseInt(id, 10)
|
||||||
|
);
|
||||||
|
if (localBeneficiary) {
|
||||||
|
setBeneficiary(localBeneficiary);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await api.getBeneficiary(parseInt(id, 10));
|
||||||
|
if (response.ok && response.data) {
|
||||||
|
setBeneficiary(response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load beneficiary:', err);
|
||||||
|
} finally {
|
||||||
|
setIsBeneficiaryLoading(false);
|
||||||
|
}
|
||||||
|
}, [id, isLocal, localBeneficiaries]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBeneficiary();
|
||||||
|
}, [loadBeneficiary]);
|
||||||
|
|
||||||
|
// Edit modal animation
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: isEditModalVisible ? 1 : 0,
|
||||||
|
duration: 250,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, [isEditModalVisible]);
|
||||||
|
|
||||||
|
const handleEditPress = () => {
|
||||||
|
if (beneficiary) {
|
||||||
|
setEditForm({
|
||||||
|
name: beneficiary.name || '',
|
||||||
|
address: beneficiary.address || '',
|
||||||
|
avatar: beneficiary.avatar,
|
||||||
|
});
|
||||||
|
setIsEditModalVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickAvatar = async () => {
|
||||||
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('Permission needed', 'Please allow access to your photo library.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets[0]) {
|
||||||
|
setEditForm(prev => ({ ...prev, avatar: result.assets[0].uri }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!editForm.name.trim()) {
|
||||||
|
toast.error('Error', 'Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLocal && id) {
|
||||||
|
const updated = await updateLocalBeneficiary(parseInt(id, 10), {
|
||||||
|
name: editForm.name.trim(),
|
||||||
|
address: editForm.address.trim() || undefined,
|
||||||
|
avatar: editForm.avatar,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
setBeneficiary(updated);
|
||||||
|
setCurrentBeneficiary(updated);
|
||||||
|
setIsEditModalVisible(false);
|
||||||
|
toast.success('Profile Updated', 'Changes saved successfully');
|
||||||
|
} else {
|
||||||
|
toast.error('Error', 'Failed to save changes.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For API beneficiaries - would call backend here
|
||||||
|
toast.info('Coming Soon', 'Editing requires backend API.');
|
||||||
|
setIsEditModalVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Build dashboard URL with beneficiary ID
|
// Build dashboard URL with beneficiary ID
|
||||||
const dashboardUrl = id ? getDashboardUrl(id) : 'https://react.eluxnetworks.net/dashboard';
|
const dashboardUrl = id ? getDashboardUrl(id) : 'https://react.eluxnetworks.net/dashboard';
|
||||||
|
|
||||||
@ -108,8 +232,8 @@ export default function BeneficiaryDashboardScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wait for token to load before showing WebView (skip for local beneficiaries)
|
// Wait for beneficiary data and token to load
|
||||||
if (!isTokenLoaded && !isLocal) {
|
if (isBeneficiaryLoading || (!isTokenLoaded && !isLocal)) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@ -127,6 +251,26 @@ export default function BeneficiaryDashboardScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NO SUBSCRIPTION - Show payment screen with Stripe integration
|
||||||
|
if (!hasActiveSubscription && beneficiary) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
|
||||||
|
<View style={styles.placeholder} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SubscriptionPayment
|
||||||
|
beneficiary={beneficiary}
|
||||||
|
onSuccess={() => loadBeneficiary()}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
@ -178,9 +322,61 @@ export default function BeneficiaryDashboardScreen() {
|
|||||||
<Ionicons name="refresh" size={22} color={AppColors.primary} />
|
<Ionicons name="refresh" size={22} color={AppColors.primary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Menu button */}
|
||||||
|
<TouchableOpacity style={styles.menuButton} onPress={() => setIsMenuVisible(!isMenuVisible)}>
|
||||||
|
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isMenuVisible && (
|
||||||
|
<View style={styles.dropdownMenu}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dropdownItem}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
handleEditPress();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="create-outline" size={20} color={AppColors.textPrimary} />
|
||||||
|
<Text style={styles.dropdownItemText}>Edit</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dropdownItem}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
router.push(`/(tabs)/beneficiaries/${id}/share`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="people-outline" size={20} color={AppColors.textPrimary} />
|
||||||
|
<Text style={styles.dropdownItemText}>Access</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dropdownItem}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
router.push(`/(tabs)/beneficiaries/${id}/equipment`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="hardware-chip-outline" size={20} color={AppColors.textPrimary} />
|
||||||
|
<Text style={styles.dropdownItemText}>Equipment</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Backdrop to close menu */}
|
||||||
|
{isMenuVisible && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.menuBackdrop}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsMenuVisible(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Dashboard Content - Mock for local, WebView for real */}
|
{/* Dashboard Content - Mock for local, WebView for real */}
|
||||||
{isLocal ? (
|
{isLocal ? (
|
||||||
<MockDashboard beneficiaryName={beneficiaryName} />
|
<MockDashboard beneficiaryName={beneficiaryName} />
|
||||||
@ -220,45 +416,98 @@ export default function BeneficiaryDashboardScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom Quick Actions */}
|
{/* Edit Modal */}
|
||||||
<View style={styles.bottomBar}>
|
<Modal
|
||||||
<TouchableOpacity
|
visible={isEditModalVisible}
|
||||||
style={styles.quickAction}
|
transparent
|
||||||
onPress={() => {
|
animationType="none"
|
||||||
if (currentBeneficiary) {
|
onRequestClose={() => setIsEditModalVisible(false)}
|
||||||
setCurrentBeneficiary(currentBeneficiary);
|
|
||||||
}
|
|
||||||
router.push('/(tabs)/chat');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Ionicons name="chatbubble-ellipses" size={22} color={AppColors.primary} />
|
<KeyboardAvoidingView
|
||||||
<Text style={styles.quickActionText}>Chat</Text>
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
</TouchableOpacity>
|
style={styles.modalOverlay}
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.quickAction}
|
|
||||||
onPress={() => router.push(`./edit`)}
|
|
||||||
>
|
>
|
||||||
<Ionicons name="create-outline" size={22} color={AppColors.primary} />
|
<Animated.View style={[styles.modalOverlay, { opacity: fadeAnim }]}>
|
||||||
<Text style={styles.quickActionText}>Edit</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.quickAction}
|
style={styles.modalBackdrop}
|
||||||
onPress={() => router.push(`/(tabs)/profile/subscription`)}
|
activeOpacity={1}
|
||||||
>
|
onPress={() => setIsEditModalVisible(false)}
|
||||||
<Ionicons name="card-outline" size={22} color={AppColors.primary} />
|
/>
|
||||||
<Text style={styles.quickActionText}>Subscribe</Text>
|
<View style={styles.modalContent}>
|
||||||
</TouchableOpacity>
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>Edit Profile</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.quickAction}
|
style={styles.modalCloseButton}
|
||||||
onPress={() => router.push(`./share`)}
|
onPress={() => setIsEditModalVisible(false)}
|
||||||
>
|
>
|
||||||
<Ionicons name="share-outline" size={22} color={AppColors.primary} />
|
<Ionicons name="close" size={24} color={AppColors.textSecondary} />
|
||||||
<Text style={styles.quickActionText}>Share</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -276,6 +525,7 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: AppColors.border,
|
borderBottomColor: AppColors.border,
|
||||||
|
zIndex: 1001,
|
||||||
},
|
},
|
||||||
backButton: {
|
backButton: {
|
||||||
padding: Spacing.xs,
|
padding: Spacing.xs,
|
||||||
@ -312,14 +562,53 @@ const styles = StyleSheet.create({
|
|||||||
headerActions: {
|
headerActions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
},
|
},
|
||||||
actionButton: {
|
actionButton: {
|
||||||
padding: Spacing.xs,
|
padding: Spacing.xs,
|
||||||
marginLeft: Spacing.xs,
|
marginLeft: Spacing.xs,
|
||||||
},
|
},
|
||||||
|
menuButton: {
|
||||||
|
padding: Spacing.xs,
|
||||||
|
marginLeft: Spacing.sm,
|
||||||
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
width: 32,
|
width: 32,
|
||||||
},
|
},
|
||||||
|
// Dropdown Menu
|
||||||
|
dropdownMenu: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 40,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
minWidth: 160,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
dropdownItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: Spacing.md,
|
||||||
|
paddingHorizontal: Spacing.lg,
|
||||||
|
gap: Spacing.md,
|
||||||
|
},
|
||||||
|
dropdownItemText: {
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
},
|
||||||
|
menuBackdrop: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 999,
|
||||||
|
},
|
||||||
webViewContainer: {
|
webViewContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
@ -351,22 +640,202 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.base,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
},
|
},
|
||||||
bottomBar: {
|
// No Subscription Styles
|
||||||
flexDirection: 'row',
|
noSubscriptionContainer: {
|
||||||
justifyContent: 'space-around',
|
flex: 1,
|
||||||
paddingVertical: Spacing.sm,
|
padding: Spacing.xl,
|
||||||
paddingHorizontal: Spacing.lg,
|
|
||||||
backgroundColor: AppColors.background,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: AppColors.border,
|
|
||||||
},
|
|
||||||
quickAction: {
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: Spacing.sm,
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
quickActionText: {
|
noSubIconContainer: {
|
||||||
fontSize: FontSizes.xs,
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
backgroundColor: AppColors.accentLight,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: Spacing.lg,
|
||||||
|
},
|
||||||
|
noSubTitle: {
|
||||||
|
fontSize: FontSizes['2xl'],
|
||||||
|
fontWeight: FontWeights.bold,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: Spacing.sm,
|
||||||
|
},
|
||||||
|
noSubSubtitle: {
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 24,
|
||||||
|
marginBottom: Spacing.xl,
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
|
},
|
||||||
|
noSubPriceCard: {
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.xl,
|
||||||
|
padding: Spacing.lg,
|
||||||
|
marginBottom: Spacing.xl,
|
||||||
|
...Shadows.sm,
|
||||||
|
},
|
||||||
|
noSubPriceHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: Spacing.md,
|
||||||
|
},
|
||||||
|
noSubPlanName: {
|
||||||
|
fontSize: FontSizes.lg,
|
||||||
|
fontWeight: FontWeights.semibold,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
},
|
||||||
|
noSubPlanDesc: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
noSubPriceBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
},
|
||||||
|
noSubPriceAmount: {
|
||||||
|
fontSize: FontSizes['2xl'],
|
||||||
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
marginTop: Spacing.xs,
|
},
|
||||||
|
noSubPriceUnit: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
noSubFeatures: {
|
||||||
|
gap: Spacing.sm,
|
||||||
|
},
|
||||||
|
noSubFeatureRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: Spacing.sm,
|
||||||
|
},
|
||||||
|
noSubFeatureText: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
},
|
||||||
|
// Edit Modal Styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
borderTopLeftRadius: BorderRadius['2xl'],
|
||||||
|
borderTopRightRadius: BorderRadius['2xl'],
|
||||||
|
padding: Spacing.lg,
|
||||||
|
maxHeight: '80%',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: Spacing.lg,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: FontSizes.xl,
|
||||||
|
fontWeight: FontWeights.bold,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
},
|
||||||
|
modalCloseButton: {
|
||||||
|
padding: Spacing.xs,
|
||||||
|
},
|
||||||
|
avatarPicker: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginBottom: Spacing.xl,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
avatarPickerImage: {
|
||||||
|
width: AvatarSizes.xl,
|
||||||
|
height: AvatarSizes.xl,
|
||||||
|
borderRadius: AvatarSizes.xl / 2,
|
||||||
|
},
|
||||||
|
avatarPickerPlaceholder: {
|
||||||
|
width: AvatarSizes.xl,
|
||||||
|
height: AvatarSizes.xl,
|
||||||
|
borderRadius: AvatarSizes.xl / 2,
|
||||||
|
backgroundColor: AppColors.primaryLighter,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarPickerLetter: {
|
||||||
|
fontSize: FontSizes['3xl'],
|
||||||
|
fontWeight: FontWeights.bold,
|
||||||
|
color: AppColors.primary,
|
||||||
|
},
|
||||||
|
avatarPickerBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: AppColors.surface,
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
marginBottom: Spacing.lg,
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
fontWeight: FontWeights.medium,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
marginBottom: Spacing.sm,
|
||||||
|
},
|
||||||
|
textInput: {
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
padding: Spacing.md,
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: AppColors.border,
|
||||||
|
},
|
||||||
|
textInputMultiline: {
|
||||||
|
minHeight: 80,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: Spacing.md,
|
||||||
|
marginTop: Spacing.lg,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: AppColors.surfaceSecondary,
|
||||||
|
paddingVertical: Spacing.md,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
fontWeight: FontWeights.semibold,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
paddingVertical: Spacing.md,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
fontWeight: FontWeights.semibold,
|
||||||
|
color: AppColors.white,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
Animated,
|
Animated,
|
||||||
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@ -23,6 +24,9 @@ import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
|||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
import { FullScreenError } from '@/components/ui/ErrorMessage';
|
import { FullScreenError } from '@/components/ui/ErrorMessage';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { SubscriptionPayment } from '@/components/SubscriptionPayment';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import MockDashboard from '@/components/MockDashboard';
|
||||||
import {
|
import {
|
||||||
AppColors,
|
AppColors,
|
||||||
BorderRadius,
|
BorderRadius,
|
||||||
@ -94,73 +98,6 @@ function NoDevicesScreen({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No Subscription Screen Component
|
|
||||||
function NoSubscriptionScreen({
|
|
||||||
beneficiary,
|
|
||||||
onSubscribe
|
|
||||||
}: {
|
|
||||||
beneficiary: Beneficiary;
|
|
||||||
onSubscribe: () => void;
|
|
||||||
}) {
|
|
||||||
const isExpired = beneficiary.subscription?.status === 'expired';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.setupContainer}>
|
|
||||||
<View style={[styles.setupIconContainer, isExpired && { backgroundColor: AppColors.errorLight }]}>
|
|
||||||
<Ionicons
|
|
||||||
name={isExpired ? "time-outline" : "diamond-outline"}
|
|
||||||
size={48}
|
|
||||||
color={isExpired ? AppColors.error : AppColors.accent}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.setupTitle}>
|
|
||||||
{isExpired ? 'Subscription Expired' : 'Subscription Required'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.setupSubtitle}>
|
|
||||||
{isExpired
|
|
||||||
? `Your subscription for ${beneficiary.name} has expired. Renew to continue monitoring.`
|
|
||||||
: `Activate a subscription to start monitoring ${beneficiary.name}'s wellness data.`
|
|
||||||
}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={styles.subscriptionPriceCard}>
|
|
||||||
<View style={styles.subscriptionPriceHeader}>
|
|
||||||
<Text style={styles.subscriptionPriceLabel}>WellNuo Pro</Text>
|
|
||||||
<View style={styles.subscriptionPriceBadge}>
|
|
||||||
<Text style={styles.subscriptionPriceAmount}>$49</Text>
|
|
||||||
<Text style={styles.subscriptionPriceUnit}>/month</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.subscriptionPriceFeatures}>
|
|
||||||
<View style={styles.featureRow}>
|
|
||||||
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
|
|
||||||
<Text style={styles.featureRowText}>24/7 AI monitoring</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureRow}>
|
|
||||||
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
|
|
||||||
<Text style={styles.featureRowText}>Unlimited chat with Julia AI</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureRow}>
|
|
||||||
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
|
|
||||||
<Text style={styles.featureRowText}>Detailed activity reports</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureRow}>
|
|
||||||
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
|
|
||||||
<Text style={styles.featureRowText}>Smart alerts & notifications</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
title={isExpired ? "Renew Subscription" : "Subscribe Now"}
|
|
||||||
onPress={onSubscribe}
|
|
||||||
fullWidth
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equipment status configuration
|
// Equipment status configuration
|
||||||
const equipmentStatusInfo = {
|
const equipmentStatusInfo = {
|
||||||
@ -292,6 +229,7 @@ function AwaitingEquipmentScreen({
|
|||||||
export default function BeneficiaryDetailScreen() {
|
export default function BeneficiaryDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary, removeLocalBeneficiary } = useBeneficiary();
|
const { setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary, removeLocalBeneficiary } = useBeneficiary();
|
||||||
|
const toast = useToast();
|
||||||
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
|
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
@ -327,6 +265,9 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
return 'ready';
|
return 'ready';
|
||||||
}, [beneficiary, isLoading]);
|
}, [beneficiary, isLoading]);
|
||||||
|
|
||||||
|
// Dropdown menu state
|
||||||
|
const [isMenuVisible, setIsMenuVisible] = useState(false);
|
||||||
|
|
||||||
// Edit modal state
|
// Edit modal state
|
||||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
@ -399,20 +340,12 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
|
|
||||||
const handleGetSensors = () => {
|
const handleGetSensors = () => {
|
||||||
// For now, show info or redirect to purchase
|
// For now, show info or redirect to purchase
|
||||||
Alert.alert(
|
toast.info(
|
||||||
'Get WellNuo Sensors',
|
'Get WellNuo Sensors',
|
||||||
'WellNuo sensor kits include motion sensors, door sensors, and temperature monitors.\n\nVisit wellnuo.com to order.',
|
'WellNuo sensor kits include motion sensors, door sensors, and temperature monitors. Visit wellnuo.com to order.'
|
||||||
[{ text: 'OK' }]
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubscribe = () => {
|
|
||||||
router.push({
|
|
||||||
pathname: '/(tabs)/beneficiaries/[id]/purchase',
|
|
||||||
params: { id: id! },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkReceived = async () => {
|
const handleMarkReceived = async () => {
|
||||||
if (!beneficiary || !id) return;
|
if (!beneficiary || !id) return;
|
||||||
|
|
||||||
@ -427,7 +360,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
}
|
}
|
||||||
// For API beneficiaries, would call backend here
|
// For API beneficiaries, would call backend here
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Alert.alert('Error', 'Failed to update status');
|
toast.error('Error', 'Failed to update status');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -452,7 +385,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
const handlePickAvatar = async () => {
|
const handlePickAvatar = async () => {
|
||||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
if (status !== 'granted') {
|
if (status !== 'granted') {
|
||||||
Alert.alert('Permission needed', 'Please allow access to your photo library.');
|
toast.error('Permission needed', 'Please allow access to your photo library.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -470,7 +403,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
|
|
||||||
const handleSaveEdit = async () => {
|
const handleSaveEdit = async () => {
|
||||||
if (!editForm.name.trim()) {
|
if (!editForm.name.trim()) {
|
||||||
Alert.alert('Error', 'Name is required');
|
toast.error('Error', 'Name is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -484,24 +417,18 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
if (updated) {
|
if (updated) {
|
||||||
setBeneficiary(updated);
|
setBeneficiary(updated);
|
||||||
setIsEditModalVisible(false);
|
setIsEditModalVisible(false);
|
||||||
|
toast.success('Saved', 'Profile updated successfully');
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Error', 'Failed to save changes.');
|
toast.error('Error', 'Failed to save changes.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
toast.info('Demo Mode', 'Saving beneficiary data requires backend API.');
|
||||||
'Demo Mode',
|
setIsEditModalVisible(false);
|
||||||
'Saving beneficiary data requires backend API.',
|
|
||||||
[{ text: 'OK', onPress: () => setIsEditModalVisible(false) }]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showComingSoon = (featureName: string) => {
|
const showComingSoon = (featureName: string) => {
|
||||||
Alert.alert(
|
toast.info('Coming Soon', `${featureName} is currently in development.`);
|
||||||
'Coming Soon',
|
|
||||||
`${featureName} is currently in development.`,
|
|
||||||
[{ text: 'OK' }]
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteBeneficiary = () => {
|
const handleDeleteBeneficiary = () => {
|
||||||
@ -520,7 +447,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
await removeLocalBeneficiary(parseInt(id, 10));
|
await removeLocalBeneficiary(parseInt(id, 10));
|
||||||
router.replace('/(tabs)');
|
router.replace('/(tabs)');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Alert.alert('Error', 'Failed to remove beneficiary');
|
toast.error('Error', 'Failed to remove beneficiary');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -566,9 +493,9 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
|
|
||||||
case 'no_subscription':
|
case 'no_subscription':
|
||||||
return (
|
return (
|
||||||
<NoSubscriptionScreen
|
<SubscriptionPayment
|
||||||
beneficiary={beneficiary}
|
beneficiary={beneficiary}
|
||||||
onSubscribe={handleSubscribe}
|
onSuccess={() => loadBeneficiary(false)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -628,16 +555,6 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||||
<View style={styles.actionsRow}>
|
<View style={styles.actionsRow}>
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.actionButton}
|
|
||||||
onPress={() => router.push(`/(tabs)/beneficiaries/${id}/dashboard`)}
|
|
||||||
>
|
|
||||||
<View style={[styles.actionButtonIcon, { backgroundColor: AppColors.primaryLighter }]}>
|
|
||||||
<Ionicons name="stats-chart" size={22} color={AppColors.primary} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.actionButtonText}>Dashboard</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.actionButton}
|
style={styles.actionButton}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@ -648,7 +565,17 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
<View style={[styles.actionButtonIcon, { backgroundColor: AppColors.accentLight }]}>
|
<View style={[styles.actionButtonIcon, { backgroundColor: AppColors.accentLight }]}>
|
||||||
<Ionicons name="chatbubbles" size={22} color={AppColors.accent} />
|
<Ionicons name="chatbubbles" size={22} color={AppColors.accent} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.actionButtonText}>Chat</Text>
|
<Text style={styles.actionButtonText}>Chat with Julia</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => router.push(`/(tabs)/beneficiaries/${id}/equipment`)}
|
||||||
|
>
|
||||||
|
<View style={[styles.actionButtonIcon, { backgroundColor: AppColors.primaryLighter }]}>
|
||||||
|
<Ionicons name="hardware-chip" size={22} color={AppColors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionButtonText}>Equipment</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -745,6 +672,9 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Activity Dashboard */}
|
||||||
|
<MockDashboard beneficiaryName={beneficiary.name} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -758,10 +688,106 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
<Ionicons name="arrow-back" size={22} color={AppColors.textPrimary} />
|
<Ionicons name="arrow-back" size={22} color={AppColors.textPrimary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
||||||
<TouchableOpacity style={styles.headerButton} onPress={handleEditPress}>
|
<View>
|
||||||
<Ionicons name="create-outline" size={22} color={AppColors.primary} />
|
<TouchableOpacity style={styles.headerButton} onPress={() => setIsMenuVisible(!isMenuVisible)}>
|
||||||
|
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isMenuVisible && (
|
||||||
|
<View style={styles.dropdownMenu}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dropdownItem}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
setCurrentBeneficiary(beneficiary);
|
||||||
|
router.push('/(tabs)/chat');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="chatbubbles-outline" size={20} color={AppColors.textPrimary} />
|
||||||
|
<Text style={styles.dropdownItemText}>Chat</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dropdownItem}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
router.push(`/(tabs)/beneficiaries/${id}/dashboard`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="stats-chart-outline" size={20} color={AppColors.textPrimary} />
|
||||||
|
<Text style={styles.dropdownItemText}>Dashboard</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dropdownItem}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
handleEditPress();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="create-outline" size={20} color={AppColors.textPrimary} />
|
||||||
|
<Text style={styles.dropdownItemText}>Edit</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dropdownItem}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
router.push(`/(tabs)/beneficiaries/${id}/share`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="share-outline" size={20} color={AppColors.textPrimary} />
|
||||||
|
<Text style={styles.dropdownItemText}>Share</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dropdownItem}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
router.push(`/(tabs)/beneficiaries/${id}/subscription`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="diamond-outline" size={20} color={AppColors.textPrimary} />
|
||||||
|
<Text style={styles.dropdownItemText}>Subscription</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dropdownItem}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
router.push(`/(tabs)/beneficiaries/${id}/equipment`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="hardware-chip-outline" size={20} color={AppColors.textPrimary} />
|
||||||
|
<Text style={styles.dropdownItemText}>Equipment</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{isLocal && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.dropdownItem, styles.dropdownItemDanger]}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
handleDeleteBeneficiary();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={20} color={AppColors.error} />
|
||||||
|
<Text style={[styles.dropdownItemText, styles.dropdownItemTextDanger]}>Remove</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Backdrop to close menu */}
|
||||||
|
{isMenuVisible && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.menuBackdrop}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsMenuVisible(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
|
||||||
@ -885,6 +911,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: Spacing.md,
|
paddingVertical: Spacing.md,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: AppColors.border,
|
borderBottomColor: AppColors.border,
|
||||||
|
zIndex: 1001,
|
||||||
},
|
},
|
||||||
headerButton: {
|
headerButton: {
|
||||||
width: 40,
|
width: 40,
|
||||||
@ -1537,4 +1564,37 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: FontWeights.semibold,
|
fontWeight: FontWeights.semibold,
|
||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
},
|
},
|
||||||
|
// Dropdown Menu
|
||||||
|
dropdownMenu: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 44,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
minWidth: 160,
|
||||||
|
...Shadows.lg,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
dropdownItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: Spacing.md,
|
||||||
|
paddingHorizontal: Spacing.lg,
|
||||||
|
gap: Spacing.md,
|
||||||
|
},
|
||||||
|
dropdownItemText: {
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
},
|
||||||
|
dropdownItemDanger: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: AppColors.borderLight,
|
||||||
|
},
|
||||||
|
dropdownItemTextDanger: {
|
||||||
|
color: AppColors.error,
|
||||||
|
},
|
||||||
|
menuBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
zIndex: 999,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -10,32 +10,75 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
|
RefreshControl,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { api } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
AppColors,
|
AppColors,
|
||||||
BorderRadius,
|
BorderRadius,
|
||||||
FontSizes,
|
FontSizes,
|
||||||
FontWeights,
|
FontWeights,
|
||||||
Spacing,
|
Spacing,
|
||||||
Shadows,
|
|
||||||
} from '@/constants/theme';
|
} from '@/constants/theme';
|
||||||
|
|
||||||
type Role = 'caretaker' | 'guardian';
|
type Role = 'caretaker' | 'guardian';
|
||||||
|
|
||||||
|
interface Invitation {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
label?: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ShareAccessScreen() {
|
export default function ShareAccessScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { currentBeneficiary } = useBeneficiary();
|
const { currentBeneficiary } = useBeneficiary();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [label, setLabel] = useState('');
|
|
||||||
const [role, setRole] = useState<Role>('caretaker');
|
const [role, setRole] = useState<Role>('caretaker');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||||
|
const [isLoadingInvitations, setIsLoadingInvitations] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const beneficiaryName = currentBeneficiary?.name || 'this person';
|
const beneficiaryName = currentBeneficiary?.name || 'this person';
|
||||||
|
const currentUserEmail = user?.email?.toLowerCase();
|
||||||
|
|
||||||
|
// Load invitations on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
loadInvitations();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadInvitations = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.getInvitations(id);
|
||||||
|
if (response.ok && response.data) {
|
||||||
|
setInvitations(response.data.invitations || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load invitations:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingInvitations(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
loadInvitations();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
const validateEmail = (email: string): boolean => {
|
const validateEmail = (email: string): boolean => {
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
@ -44,35 +87,47 @@ export default function ShareAccessScreen() {
|
|||||||
|
|
||||||
const handleSendInvite = async () => {
|
const handleSendInvite = async () => {
|
||||||
const trimmedEmail = email.trim().toLowerCase();
|
const trimmedEmail = email.trim().toLowerCase();
|
||||||
const trimmedLabel = label.trim();
|
|
||||||
|
|
||||||
if (!trimmedEmail) {
|
if (!trimmedEmail) {
|
||||||
Alert.alert('Email Required', 'Please enter an email address.');
|
Alert.alert('Error', 'Please enter an email address.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateEmail(trimmedEmail)) {
|
if (!validateEmail(trimmedEmail)) {
|
||||||
Alert.alert('Invalid Email', 'Please enter a valid email address.');
|
Alert.alert('Error', 'Please enter a valid email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if inviting self
|
||||||
|
if (currentUserEmail && trimmedEmail === currentUserEmail) {
|
||||||
|
Alert.alert('Error', 'You cannot invite yourself.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already invited
|
||||||
|
const alreadyInvited = invitations.some(inv => inv.email.toLowerCase() === trimmedEmail);
|
||||||
|
if (alreadyInvited) {
|
||||||
|
Alert.alert('Error', 'This person has already been invited.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Send invitation via API
|
const response = await api.sendInvitation({
|
||||||
// await api.sendInvitation({
|
beneficiaryId: id!,
|
||||||
// beneficiaryId: id,
|
email: trimmedEmail,
|
||||||
// email: trimmedEmail,
|
role: role,
|
||||||
// label: trimmedLabel,
|
});
|
||||||
// role: role,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// For now, show success message
|
if (response.ok) {
|
||||||
Alert.alert(
|
setEmail('');
|
||||||
'Invitation Sent',
|
setRole('caretaker');
|
||||||
`An invitation has been sent to ${trimmedEmail} to ${role === 'guardian' ? 'manage' : 'view'} ${beneficiaryName}.`,
|
loadInvitations();
|
||||||
[{ text: 'OK', onPress: () => router.back() }]
|
Alert.alert('Success', `Invitation sent to ${trimmedEmail}`);
|
||||||
);
|
} else {
|
||||||
|
Alert.alert('Error', response.error?.message || 'Failed to send invitation');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send invitation:', error);
|
console.error('Failed to send invitation:', error);
|
||||||
Alert.alert('Error', 'Failed to send invitation. Please try again.');
|
Alert.alert('Error', 'Failed to send invitation. Please try again.');
|
||||||
@ -81,6 +136,46 @@ export default function ShareAccessScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveInvitation = (invitation: Invitation) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Remove Access',
|
||||||
|
`Remove ${invitation.email} from accessing ${beneficiaryName}?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Remove',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.deleteInvitation(invitation.id);
|
||||||
|
if (response.ok) {
|
||||||
|
loadInvitations();
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', 'Failed to remove access');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Failed to remove access');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleLabel = (role: string): string => {
|
||||||
|
if (role === 'owner' || role === 'guardian') return 'Guardian';
|
||||||
|
return 'Caretaker';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'accepted': return AppColors.success;
|
||||||
|
case 'pending': return AppColors.warning;
|
||||||
|
case 'rejected': return AppColors.error;
|
||||||
|
default: return AppColors.textMuted;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -88,7 +183,7 @@ export default function ShareAccessScreen() {
|
|||||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.headerTitle}>Share Access</Text>
|
<Text style={styles.headerTitle}>Access</Text>
|
||||||
<View style={styles.placeholder} />
|
<View style={styles.placeholder} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -99,23 +194,18 @@ export default function ShareAccessScreen() {
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* Info Banner */}
|
{/* Invite Section */}
|
||||||
<View style={styles.infoBanner}>
|
<View style={styles.section}>
|
||||||
<Ionicons name="people" size={24} color={AppColors.primary} />
|
<Text style={styles.sectionTitle}>Invite Someone</Text>
|
||||||
<Text style={styles.infoBannerText}>
|
|
||||||
Invite family members or caregivers to help monitor {beneficiaryName}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Email Input */}
|
<View style={styles.inputRow}>
|
||||||
<View style={styles.inputGroup}>
|
|
||||||
<Text style={styles.inputLabel}>Email Address</Text>
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<Ionicons name="mail-outline" size={20} color={AppColors.textMuted} />
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Enter email address"
|
placeholder="Email address"
|
||||||
placeholderTextColor={AppColors.textMuted}
|
placeholderTextColor={AppColors.textMuted}
|
||||||
value={email}
|
value={email}
|
||||||
onChangeText={setEmail}
|
onChangeText={setEmail}
|
||||||
@ -124,105 +214,95 @@ export default function ShareAccessScreen() {
|
|||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
editable={!isLoading}
|
editable={!isLoading}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
</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
|
<TouchableOpacity
|
||||||
style={[styles.sendButton, isLoading && styles.sendButtonDisabled]}
|
style={[styles.sendButton, isLoading && styles.sendButtonDisabled]}
|
||||||
onPress={handleSendInvite}
|
onPress={handleSendInvite}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator color={AppColors.white} />
|
<ActivityIndicator size="small" color={AppColors.white} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Ionicons name="send" size={18} color={AppColors.white} />
|
||||||
<Ionicons name="send" size={20} color={AppColors.white} />
|
|
||||||
<Text style={styles.sendButtonText}>Send Invitation</Text>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Help Text */}
|
{/* Role Toggle */}
|
||||||
<Text style={styles.helpText}>
|
<View style={styles.roleToggle}>
|
||||||
The person will receive an email with instructions to access {beneficiaryName}'s information.
|
<TouchableOpacity
|
||||||
|
style={[styles.roleButton, role === 'caretaker' && styles.roleButtonActive]}
|
||||||
|
onPress={() => setRole('caretaker')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.roleButtonText, role === 'caretaker' && styles.roleButtonTextActive]}>
|
||||||
|
Caretaker
|
||||||
</Text>
|
</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>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@ -261,114 +341,148 @@ const styles = StyleSheet.create({
|
|||||||
padding: Spacing.lg,
|
padding: Spacing.lg,
|
||||||
paddingBottom: Spacing.xxl,
|
paddingBottom: Spacing.xxl,
|
||||||
},
|
},
|
||||||
infoBanner: {
|
section: {
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: AppColors.primaryLighter,
|
|
||||||
padding: Spacing.md,
|
|
||||||
borderRadius: BorderRadius.lg,
|
|
||||||
marginBottom: Spacing.xl,
|
marginBottom: Spacing.xl,
|
||||||
gap: Spacing.md,
|
|
||||||
},
|
},
|
||||||
infoBannerText: {
|
sectionTitle: {
|
||||||
flex: 1,
|
|
||||||
fontSize: FontSizes.sm,
|
fontSize: FontSizes.sm,
|
||||||
color: AppColors.textPrimary,
|
fontWeight: FontWeights.semibold,
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
inputGroup: {
|
|
||||||
marginBottom: Spacing.lg,
|
|
||||||
},
|
|
||||||
inputLabel: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
fontWeight: FontWeights.medium,
|
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
marginBottom: Spacing.sm,
|
marginBottom: Spacing.md,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
inputContainer: {
|
inputRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
gap: Spacing.sm,
|
||||||
backgroundColor: AppColors.surface,
|
|
||||||
borderRadius: BorderRadius.lg,
|
|
||||||
paddingHorizontal: Spacing.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: AppColors.border,
|
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: Spacing.md,
|
|
||||||
marginLeft: Spacing.sm,
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
},
|
|
||||||
roleOption: {
|
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
borderRadius: BorderRadius.lg,
|
borderRadius: BorderRadius.lg,
|
||||||
padding: Spacing.md,
|
paddingHorizontal: Spacing.md,
|
||||||
marginBottom: Spacing.sm,
|
paddingVertical: Spacing.md,
|
||||||
borderWidth: 2,
|
fontSize: FontSizes.base,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
borderWidth: 1,
|
||||||
borderColor: AppColors.border,
|
borderColor: AppColors.border,
|
||||||
},
|
},
|
||||||
roleOptionSelected: {
|
|
||||||
borderColor: AppColors.primary,
|
|
||||||
backgroundColor: AppColors.primaryLighter,
|
|
||||||
},
|
|
||||||
roleHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
roleIcon: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: BorderRadius.md,
|
|
||||||
backgroundColor: AppColors.surfaceSecondary,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginRight: Spacing.md,
|
|
||||||
},
|
|
||||||
roleIconSelected: {
|
|
||||||
backgroundColor: AppColors.primary,
|
|
||||||
},
|
|
||||||
roleInfo: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
roleTitle: {
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
fontWeight: FontWeights.semibold,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
},
|
|
||||||
roleTitleSelected: {
|
|
||||||
color: AppColors.primary,
|
|
||||||
},
|
|
||||||
roleDescription: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
sendButton: {
|
sendButton: {
|
||||||
flexDirection: 'row',
|
width: 48,
|
||||||
alignItems: 'center',
|
height: 48,
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: AppColors.primary,
|
|
||||||
paddingVertical: Spacing.md,
|
|
||||||
borderRadius: BorderRadius.lg,
|
borderRadius: BorderRadius.lg,
|
||||||
marginTop: Spacing.lg,
|
backgroundColor: AppColors.primary,
|
||||||
gap: Spacing.sm,
|
justifyContent: 'center',
|
||||||
...Shadows.primary,
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
sendButtonDisabled: {
|
sendButtonDisabled: {
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
},
|
},
|
||||||
sendButtonText: {
|
roleToggle: {
|
||||||
fontSize: FontSizes.base,
|
flexDirection: 'row',
|
||||||
|
backgroundColor: AppColors.surfaceSecondary,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
padding: 4,
|
||||||
|
marginTop: Spacing.md,
|
||||||
|
},
|
||||||
|
roleButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: Spacing.sm,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: BorderRadius.md,
|
||||||
|
},
|
||||||
|
roleButtonActive: {
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
},
|
||||||
|
roleButtonText: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
fontWeight: FontWeights.medium,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
},
|
||||||
|
roleButtonTextActive: {
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeights.semibold,
|
||||||
|
},
|
||||||
|
roleHint: {
|
||||||
|
fontSize: FontSizes.xs,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
marginTop: Spacing.sm,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
padding: Spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: Spacing.xl,
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
marginTop: Spacing.sm,
|
||||||
|
},
|
||||||
|
invitationsList: {
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
invitationItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: Spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: AppColors.border,
|
||||||
|
},
|
||||||
|
invitationInfo: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
invitationAvatar: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: AppColors.primaryLight,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: Spacing.md,
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
fontWeight: FontWeights.semibold,
|
fontWeight: FontWeights.semibold,
|
||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
},
|
},
|
||||||
helpText: {
|
invitationDetails: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
invitationEmail: {
|
||||||
fontSize: FontSizes.sm,
|
fontSize: FontSizes.sm,
|
||||||
color: AppColors.textMuted,
|
fontWeight: FontWeights.medium,
|
||||||
textAlign: 'center',
|
color: AppColors.textPrimary,
|
||||||
marginTop: Spacing.lg,
|
},
|
||||||
lineHeight: 20,
|
invitationMeta: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
invitationRole: {
|
||||||
|
fontSize: FontSizes.xs,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
marginHorizontal: Spacing.xs,
|
||||||
|
},
|
||||||
|
invitationStatus: {
|
||||||
|
fontSize: FontSizes.xs,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
padding: Spacing.xs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -10,13 +10,12 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import { usePaymentSheet } from '@stripe/stripe-react-native';
|
import { usePaymentSheet } from '@stripe/stripe-react-native';
|
||||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
|
||||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useSubscription } from '@/hooks/useSubscription';
|
import type { Beneficiary, BeneficiarySubscription } from '@/types';
|
||||||
import type { BeneficiarySubscription } from '@/types';
|
|
||||||
|
|
||||||
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
||||||
const SUBSCRIPTION_PRICE = 49; // $49/month
|
const SUBSCRIPTION_PRICE = 49; // $49/month
|
||||||
@ -42,16 +41,44 @@ function PlanFeature({ text, included }: PlanFeatureProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SubscriptionScreen() {
|
export default function SubscriptionScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
|
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { currentBeneficiary, updateLocalBeneficiary } = useBeneficiary();
|
const { getBeneficiaryById, updateLocalBeneficiary } = useBeneficiary();
|
||||||
const { isActive, daysRemaining, subscription, beneficiaryName } = useSubscription();
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBeneficiary();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadBeneficiary = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getBeneficiaryById(id);
|
||||||
|
setBeneficiary(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load beneficiary:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = beneficiary?.subscription;
|
||||||
|
const isActive = subscription?.status === 'active' &&
|
||||||
|
subscription?.endDate &&
|
||||||
|
new Date(subscription.endDate) > new Date();
|
||||||
|
|
||||||
|
const daysRemaining = subscription?.endDate
|
||||||
|
? Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
const handleSubscribe = async () => {
|
const handleSubscribe = async () => {
|
||||||
if (!currentBeneficiary) {
|
if (!beneficiary) {
|
||||||
Alert.alert('No Beneficiary', 'Please select a loved one first.');
|
Alert.alert('Error', 'Beneficiary data not loaded.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,8 +98,8 @@ export default function SubscriptionScreen() {
|
|||||||
type: 'subscription',
|
type: 'subscription',
|
||||||
planType: 'monthly',
|
planType: 'monthly',
|
||||||
userId: user?.user_id || 'guest',
|
userId: user?.user_id || 'guest',
|
||||||
beneficiaryId: currentBeneficiary.id,
|
beneficiaryId: beneficiary.id,
|
||||||
beneficiaryName: currentBeneficiary.name,
|
beneficiaryName: beneficiary.name,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -130,13 +157,16 @@ export default function SubscriptionScreen() {
|
|||||||
price: SUBSCRIPTION_PRICE,
|
price: SUBSCRIPTION_PRICE,
|
||||||
};
|
};
|
||||||
|
|
||||||
await updateLocalBeneficiary(currentBeneficiary.id, {
|
await updateLocalBeneficiary(beneficiary.id, {
|
||||||
subscription: newSubscription,
|
subscription: newSubscription,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reload beneficiary to get updated subscription
|
||||||
|
await loadBeneficiary();
|
||||||
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Subscription Activated!',
|
'Subscription Activated!',
|
||||||
`Subscription for ${currentBeneficiary.name} is now active until ${formatDate(endDate)}.`,
|
`Subscription for ${beneficiary.name} is now active until ${formatDate(endDate)}.`,
|
||||||
[{ text: 'Great!' }]
|
[{ text: 'Great!' }]
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -170,18 +200,40 @@ export default function SubscriptionScreen() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// No beneficiary selected
|
if (isLoading) {
|
||||||
if (!currentBeneficiary) {
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<PageHeader title="Subscription" />
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Subscription</Text>
|
||||||
|
<View style={styles.placeholder} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!beneficiary) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Subscription</Text>
|
||||||
|
<View style={styles.placeholder} />
|
||||||
|
</View>
|
||||||
<View style={styles.noBeneficiaryContainer}>
|
<View style={styles.noBeneficiaryContainer}>
|
||||||
<View style={styles.noBeneficiaryIcon}>
|
<View style={styles.noBeneficiaryIcon}>
|
||||||
<Ionicons name="person-outline" size={48} color={AppColors.textMuted} />
|
<Ionicons name="person-outline" size={48} color={AppColors.textMuted} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.noBeneficiaryTitle}>No Loved One Selected</Text>
|
<Text style={styles.noBeneficiaryTitle}>Beneficiary Not Found</Text>
|
||||||
<Text style={styles.noBeneficiaryText}>
|
<Text style={styles.noBeneficiaryText}>
|
||||||
Please select a loved one from the home screen to manage their subscription.
|
Unable to load beneficiary information.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@ -190,18 +242,26 @@ export default function SubscriptionScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<PageHeader title="Subscription" />
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Subscription</Text>
|
||||||
|
<View style={styles.placeholder} />
|
||||||
|
</View>
|
||||||
|
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
{/* Beneficiary Info */}
|
{/* Beneficiary Info */}
|
||||||
<View style={styles.beneficiaryBanner}>
|
<View style={styles.beneficiaryBanner}>
|
||||||
<View style={styles.beneficiaryAvatar}>
|
<View style={styles.beneficiaryAvatar}>
|
||||||
<Text style={styles.beneficiaryAvatarText}>
|
<Text style={styles.beneficiaryAvatarText}>
|
||||||
{currentBeneficiary.name.charAt(0).toUpperCase()}
|
{beneficiary.name.charAt(0).toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.beneficiaryInfo}>
|
<View style={styles.beneficiaryInfo}>
|
||||||
<Text style={styles.beneficiaryLabel}>Subscription for</Text>
|
<Text style={styles.beneficiaryLabel}>Subscription for</Text>
|
||||||
<Text style={styles.beneficiaryName}>{currentBeneficiary.name}</Text>
|
<Text style={styles.beneficiaryName}>{beneficiary.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -233,7 +293,7 @@ export default function SubscriptionScreen() {
|
|||||||
<Text style={styles.statusTitle}>
|
<Text style={styles.statusTitle}>
|
||||||
{subscription?.status === 'expired'
|
{subscription?.status === 'expired'
|
||||||
? 'Subscription has expired'
|
? 'Subscription has expired'
|
||||||
: `Subscribe for ${beneficiaryName}`}
|
: `Subscribe for ${beneficiary.name}`}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.statusDescription}>
|
<Text style={styles.statusDescription}>
|
||||||
Get full access to monitoring features
|
Get full access to monitoring features
|
||||||
@ -313,6 +373,31 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
},
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
|
paddingVertical: Spacing.sm,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: AppColors.border,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: Spacing.xs,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: FontSizes.lg,
|
||||||
|
fontWeight: FontWeights.semibold,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
width: 32,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
// No beneficiary state
|
// No beneficiary state
|
||||||
noBeneficiaryContainer: {
|
noBeneficiaryContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -331,7 +416,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
noBeneficiaryTitle: {
|
noBeneficiaryTitle: {
|
||||||
fontSize: FontSizes.xl,
|
fontSize: FontSizes.xl,
|
||||||
fontWeight: '700',
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
marginBottom: Spacing.sm,
|
marginBottom: Spacing.sm,
|
||||||
},
|
},
|
||||||
@ -361,7 +446,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
beneficiaryAvatarText: {
|
beneficiaryAvatarText: {
|
||||||
fontSize: FontSizes.xl,
|
fontSize: FontSizes.xl,
|
||||||
fontWeight: '700',
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
},
|
},
|
||||||
beneficiaryInfo: {
|
beneficiaryInfo: {
|
||||||
@ -373,7 +458,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
beneficiaryName: {
|
beneficiaryName: {
|
||||||
fontSize: FontSizes.lg,
|
fontSize: FontSizes.lg,
|
||||||
fontWeight: '700',
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
},
|
},
|
||||||
// Status banner
|
// Status banner
|
||||||
@ -393,7 +478,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
activeBadgeText: {
|
activeBadgeText: {
|
||||||
fontSize: FontSizes.sm,
|
fontSize: FontSizes.sm,
|
||||||
fontWeight: '700',
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.success,
|
color: AppColors.success,
|
||||||
},
|
},
|
||||||
inactiveBadge: {
|
inactiveBadge: {
|
||||||
@ -407,12 +492,12 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
inactiveBadgeText: {
|
inactiveBadgeText: {
|
||||||
fontSize: FontSizes.sm,
|
fontSize: FontSizes.sm,
|
||||||
fontWeight: '700',
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.error,
|
color: AppColors.error,
|
||||||
},
|
},
|
||||||
statusTitle: {
|
statusTitle: {
|
||||||
fontSize: FontSizes.xl,
|
fontSize: FontSizes.xl,
|
||||||
fontWeight: '700',
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
marginTop: Spacing.md,
|
marginTop: Spacing.md,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
@ -429,7 +514,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
daysRemainingNumber: {
|
daysRemainingNumber: {
|
||||||
fontSize: 48,
|
fontSize: 48,
|
||||||
fontWeight: '700',
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
},
|
},
|
||||||
daysRemainingLabel: {
|
daysRemainingLabel: {
|
||||||
@ -459,7 +544,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
proBadgeText: {
|
proBadgeText: {
|
||||||
fontSize: FontSizes.lg,
|
fontSize: FontSizes.lg,
|
||||||
fontWeight: '700',
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
},
|
},
|
||||||
priceContainer: {
|
priceContainer: {
|
||||||
@ -469,7 +554,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
priceAmount: {
|
priceAmount: {
|
||||||
fontSize: 48,
|
fontSize: 48,
|
||||||
fontWeight: '700',
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
},
|
},
|
||||||
pricePeriod: {
|
pricePeriod: {
|
||||||
@ -509,7 +594,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
subscribeButtonText: {
|
subscribeButtonText: {
|
||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.base,
|
||||||
fontWeight: '600',
|
fontWeight: FontWeights.semibold,
|
||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
},
|
},
|
||||||
securityBadge: {
|
securityBadge: {
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
@ -78,14 +79,18 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.card} onPress={handlePress} activeOpacity={0.7}>
|
<TouchableOpacity
|
||||||
|
style={[styles.card, hasNoSubscription && styles.cardNoSubscription]}
|
||||||
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<View style={styles.avatarWrapper}>
|
<View style={styles.avatarWrapper}>
|
||||||
{beneficiary.avatar ? (
|
{beneficiary.avatar ? (
|
||||||
<Image source={{ uri: beneficiary.avatar }} style={styles.avatarImage} />
|
<Image source={{ uri: beneficiary.avatar }} style={styles.avatarImage} />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.avatar}>
|
<View style={[styles.avatar, hasNoSubscription && styles.avatarNoSubscription]}>
|
||||||
<Text style={styles.avatarText}>
|
<Text style={[styles.avatarText, hasNoSubscription && styles.avatarTextNoSubscription]}>
|
||||||
{beneficiary.name.charAt(0).toUpperCase()}
|
{beneficiary.name.charAt(0).toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -104,14 +109,14 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
{/* No subscription warning */}
|
||||||
|
|
||||||
{/* Warning icon if no subscription (only for active equipment) */}
|
|
||||||
{hasNoSubscription && (
|
{hasNoSubscription && (
|
||||||
<View style={styles.warningContainer}>
|
<View style={styles.noSubscriptionBadge}>
|
||||||
<Ionicons name="warning" size={20} color={AppColors.warning} />
|
<Ionicons name="alert-circle" size={14} color={AppColors.error} />
|
||||||
|
<Text style={styles.noSubscriptionText}>No subscription</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Action button or Arrow */}
|
{/* Action button or Arrow */}
|
||||||
{equipmentStatus === 'delivered' ? (
|
{equipmentStatus === 'delivered' ? (
|
||||||
@ -140,10 +145,12 @@ export default function HomeScreen() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
// Load beneficiaries from API and combine with local ones
|
// Load beneficiaries when screen is focused (after editing profile, etc.)
|
||||||
useEffect(() => {
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
loadBeneficiaries();
|
loadBeneficiaries();
|
||||||
}, [localBeneficiaries]);
|
}, [localBeneficiaries])
|
||||||
|
);
|
||||||
|
|
||||||
const loadBeneficiaries = async () => {
|
const loadBeneficiaries = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -436,8 +443,14 @@ const styles = StyleSheet.create({
|
|||||||
padding: Spacing.md,
|
padding: Spacing.md,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'transparent',
|
||||||
...Shadows.sm,
|
...Shadows.sm,
|
||||||
},
|
},
|
||||||
|
cardNoSubscription: {
|
||||||
|
borderColor: AppColors.error,
|
||||||
|
backgroundColor: AppColors.errorLight,
|
||||||
|
},
|
||||||
avatarWrapper: {
|
avatarWrapper: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
@ -459,6 +472,12 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: FontWeights.bold,
|
fontWeight: FontWeights.bold,
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
},
|
},
|
||||||
|
avatarNoSubscription: {
|
||||||
|
backgroundColor: AppColors.errorLight,
|
||||||
|
},
|
||||||
|
avatarTextNoSubscription: {
|
||||||
|
color: AppColors.error,
|
||||||
|
},
|
||||||
info: {
|
info: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginLeft: Spacing.md,
|
marginLeft: Spacing.md,
|
||||||
@ -469,8 +488,16 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: FontWeights.semibold,
|
fontWeight: FontWeights.semibold,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
},
|
},
|
||||||
warningContainer: {
|
noSubscriptionBadge: {
|
||||||
marginRight: Spacing.sm,
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
noSubscriptionText: {
|
||||||
|
fontSize: FontSizes.xs,
|
||||||
|
fontWeight: FontWeights.medium,
|
||||||
|
color: AppColors.error,
|
||||||
},
|
},
|
||||||
statusBadge: {
|
statusBadge: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -12,6 +12,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { ProfileDrawer } from '@/components/ProfileDrawer';
|
import { ProfileDrawer } from '@/components/ProfileDrawer';
|
||||||
@ -25,6 +26,20 @@ import {
|
|||||||
AvatarSizes,
|
AvatarSizes,
|
||||||
} from '@/constants/theme';
|
} from '@/constants/theme';
|
||||||
|
|
||||||
|
// Generate stable 5-digit invite code from user identifier
|
||||||
|
const generateInviteCode = (identifier: string): string => {
|
||||||
|
// Simple hash to get a stable code
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < identifier.length; i++) {
|
||||||
|
const char = identifier.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
// Convert to 5-digit code (10000-99999 range)
|
||||||
|
const code = 10000 + (Math.abs(hash) % 90000);
|
||||||
|
return code.toString();
|
||||||
|
};
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
@ -106,6 +121,17 @@ export default function ProfileScreen() {
|
|||||||
const userName = user?.user_name || 'User';
|
const userName = user?.user_name || 'User';
|
||||||
const userInitial = userName.charAt(0).toUpperCase();
|
const userInitial = userName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
// Generate invite code based on user email or id
|
||||||
|
const inviteCode = useMemo(() => {
|
||||||
|
const identifier = user?.email || user?.user_id?.toString() || 'default';
|
||||||
|
return generateInviteCode(identifier);
|
||||||
|
}, [user?.email, user?.user_id]);
|
||||||
|
|
||||||
|
const handleCopyInviteCode = async () => {
|
||||||
|
await Clipboard.setStringAsync(inviteCode);
|
||||||
|
Alert.alert('Copied!', `Invite code "${inviteCode}" copied to clipboard`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -142,6 +168,19 @@ export default function ProfileScreen() {
|
|||||||
|
|
||||||
<Text style={styles.userName}>{userName}</Text>
|
<Text style={styles.userName}>{userName}</Text>
|
||||||
<Text style={styles.userEmail}>{user?.email || ''}</Text>
|
<Text style={styles.userEmail}>{user?.email || ''}</Text>
|
||||||
|
|
||||||
|
{/* Invite Code */}
|
||||||
|
<TouchableOpacity style={styles.inviteCodeSection} onPress={handleCopyInviteCode}>
|
||||||
|
<View style={styles.inviteCodeBadge}>
|
||||||
|
<Ionicons name="gift-outline" size={16} color={AppColors.primary} />
|
||||||
|
<Text style={styles.inviteCodeLabel}>Your Invite Code</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.inviteCodeBox}>
|
||||||
|
<Text style={styles.inviteCodeText}>{inviteCode}</Text>
|
||||||
|
<Ionicons name="copy-outline" size={18} color={AppColors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.inviteCodeHint}>Tap to copy · Share with friends for rewards</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Menu Items */}
|
{/* Menu Items */}
|
||||||
@ -150,8 +189,8 @@ export default function ProfileScreen() {
|
|||||||
style={styles.menuItem}
|
style={styles.menuItem}
|
||||||
onPress={() => router.push('/(tabs)/profile/edit')}
|
onPress={() => router.push('/(tabs)/profile/edit')}
|
||||||
>
|
>
|
||||||
<View style={[styles.menuIcon, { backgroundColor: AppColors.primarySubtle }]}>
|
<View style={styles.menuIcon}>
|
||||||
<Ionicons name="person-outline" size={22} color={AppColors.primary} />
|
<Ionicons name="person-outline" size={22} color={AppColors.textSecondary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.menuLabel}>Edit Profile</Text>
|
<Text style={styles.menuLabel}>Edit Profile</Text>
|
||||||
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
|
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
|
||||||
@ -161,8 +200,8 @@ export default function ProfileScreen() {
|
|||||||
style={styles.menuItem}
|
style={styles.menuItem}
|
||||||
onPress={() => router.push('/(tabs)')}
|
onPress={() => router.push('/(tabs)')}
|
||||||
>
|
>
|
||||||
<View style={[styles.menuIcon, { backgroundColor: AppColors.accentLight }]}>
|
<View style={styles.menuIcon}>
|
||||||
<Ionicons name="people-outline" size={22} color={AppColors.accent} />
|
<Ionicons name="people-outline" size={22} color={AppColors.textSecondary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.menuLabel}>My Loved Ones</Text>
|
<Text style={styles.menuLabel}>My Loved Ones</Text>
|
||||||
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
|
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
|
||||||
@ -172,21 +211,10 @@ export default function ProfileScreen() {
|
|||||||
style={styles.menuItem}
|
style={styles.menuItem}
|
||||||
onPress={() => setDrawerVisible(true)}
|
onPress={() => setDrawerVisible(true)}
|
||||||
>
|
>
|
||||||
<View style={[styles.menuIcon, { backgroundColor: AppColors.surfaceSecondary }]}>
|
<View style={styles.menuIcon}>
|
||||||
<Ionicons name="settings-outline" size={22} color={AppColors.textSecondary} />
|
<Ionicons name="settings-outline" size={22} color={AppColors.textSecondary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.menuLabel}>App Settings</Text>
|
<Text style={styles.menuLabel}>Settings</Text>
|
||||||
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.menuItem}
|
|
||||||
onPress={() => router.push('/(tabs)/profile/help')}
|
|
||||||
>
|
|
||||||
<View style={[styles.menuIcon, { backgroundColor: AppColors.successLight }]}>
|
|
||||||
<Ionicons name="help-circle-outline" size={22} color={AppColors.success} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.menuLabel}>Help & Support</Text>
|
|
||||||
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
|
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@ -194,7 +222,7 @@ export default function ProfileScreen() {
|
|||||||
style={[styles.menuItem, styles.menuItemLast]}
|
style={[styles.menuItem, styles.menuItemLast]}
|
||||||
onPress={handleLogout}
|
onPress={handleLogout}
|
||||||
>
|
>
|
||||||
<View style={[styles.menuIcon, { backgroundColor: AppColors.errorLight }]}>
|
<View style={[styles.menuIcon, styles.menuIconDanger]}>
|
||||||
<Ionicons name="log-out-outline" size={22} color={AppColors.error} />
|
<Ionicons name="log-out-outline" size={22} color={AppColors.error} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.menuLabel, { color: AppColors.error }]}>Log Out</Text>
|
<Text style={[styles.menuLabel, { color: AppColors.error }]}>Log Out</Text>
|
||||||
@ -306,6 +334,46 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: FontSizes.sm,
|
fontSize: FontSizes.sm,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
},
|
},
|
||||||
|
// Invite Code
|
||||||
|
inviteCodeSection: {
|
||||||
|
marginTop: Spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: Spacing.md,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: AppColors.border,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
inviteCodeBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: Spacing.xs,
|
||||||
|
marginBottom: Spacing.sm,
|
||||||
|
},
|
||||||
|
inviteCodeLabel: {
|
||||||
|
fontSize: FontSizes.xs,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeights.medium,
|
||||||
|
},
|
||||||
|
inviteCodeBox: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: AppColors.primaryLighter,
|
||||||
|
paddingVertical: Spacing.sm,
|
||||||
|
paddingHorizontal: Spacing.lg,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
gap: Spacing.sm,
|
||||||
|
},
|
||||||
|
inviteCodeText: {
|
||||||
|
fontSize: FontSizes.xl,
|
||||||
|
fontWeight: FontWeights.bold,
|
||||||
|
color: AppColors.primary,
|
||||||
|
letterSpacing: 3,
|
||||||
|
},
|
||||||
|
inviteCodeHint: {
|
||||||
|
fontSize: FontSizes.xs,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
marginTop: Spacing.xs,
|
||||||
|
},
|
||||||
// Menu Section
|
// Menu Section
|
||||||
menuSection: {
|
menuSection: {
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
@ -328,10 +396,14 @@ const styles = StyleSheet.create({
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: BorderRadius.md,
|
borderRadius: BorderRadius.md,
|
||||||
|
backgroundColor: AppColors.surfaceSecondary,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginRight: Spacing.md,
|
marginRight: Spacing.md,
|
||||||
},
|
},
|
||||||
|
menuIconDanger: {
|
||||||
|
backgroundColor: AppColors.errorLight,
|
||||||
|
},
|
||||||
menuLabel: {
|
menuLabel: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.base,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import 'react-native-reanimated';
|
|||||||
import { StripeProvider } from '@stripe/stripe-react-native';
|
import { StripeProvider } from '@stripe/stripe-react-native';
|
||||||
|
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { ToastProvider } from '@/components/ui/Toast';
|
||||||
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
|
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
|
||||||
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
|
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
@ -93,7 +94,9 @@ export default function RootLayout() {
|
|||||||
>
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BeneficiaryProvider>
|
<BeneficiaryProvider>
|
||||||
|
<ToastProvider>
|
||||||
<RootLayoutNav />
|
<RootLayoutNav />
|
||||||
|
</ToastProvider>
|
||||||
</BeneficiaryProvider>
|
</BeneficiaryProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</StripeProvider>
|
</StripeProvider>
|
||||||
|
|||||||
@ -46,7 +46,7 @@ function DrawerItem({ icon, label, onPress, rightElement, danger, badge }: Drawe
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name={icon}
|
name={icon}
|
||||||
size={20}
|
size={20}
|
||||||
color={danger ? AppColors.error : AppColors.primary}
|
color={danger ? AppColors.error : AppColors.textSecondary}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.drawerItemLabel, danger && styles.dangerText]}>
|
<Text style={[styles.drawerItemLabel, danger && styles.dangerText]}>
|
||||||
@ -150,7 +150,7 @@ export function ProfileDrawer({
|
|||||||
<Text style={styles.sectionTitle}>Preferences</Text>
|
<Text style={styles.sectionTitle}>Preferences</Text>
|
||||||
<View style={styles.sectionCard}>
|
<View style={styles.sectionCard}>
|
||||||
<DrawerItem
|
<DrawerItem
|
||||||
icon="notifications"
|
icon="notifications-outline"
|
||||||
label="Push Notifications"
|
label="Push Notifications"
|
||||||
rightElement={
|
rightElement={
|
||||||
<Switch
|
<Switch
|
||||||
@ -164,7 +164,7 @@ export function ProfileDrawer({
|
|||||||
/>
|
/>
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
<DrawerItem
|
<DrawerItem
|
||||||
icon="mail"
|
icon="mail-outline"
|
||||||
label="Email Notifications"
|
label="Email Notifications"
|
||||||
rightElement={
|
rightElement={
|
||||||
<Switch
|
<Switch
|
||||||
@ -183,12 +183,6 @@ export function ProfileDrawer({
|
|||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Account</Text>
|
<Text style={styles.sectionTitle}>Account</Text>
|
||||||
<View style={styles.sectionCard}>
|
<View style={styles.sectionCard}>
|
||||||
<DrawerItem
|
|
||||||
icon="person-outline"
|
|
||||||
label="Edit Profile"
|
|
||||||
onPress={() => handleNavigate('/(tabs)/profile/edit')}
|
|
||||||
/>
|
|
||||||
<View style={styles.divider} />
|
|
||||||
<DrawerItem
|
<DrawerItem
|
||||||
icon="language-outline"
|
icon="language-outline"
|
||||||
label="Language"
|
label="Language"
|
||||||
@ -234,13 +228,6 @@ export function ProfileDrawer({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<View style={[styles.section, styles.logoutSection]}>
|
|
||||||
<TouchableOpacity style={styles.logoutButton} onPress={onLogout}>
|
|
||||||
<Ionicons name="log-out-outline" size={20} color={AppColors.error} />
|
|
||||||
<Text style={styles.logoutText}>Log Out</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Version */}
|
{/* Version */}
|
||||||
@ -342,7 +329,7 @@ const styles = StyleSheet.create({
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: BorderRadius.md,
|
borderRadius: BorderRadius.md,
|
||||||
backgroundColor: AppColors.primarySubtle,
|
backgroundColor: AppColors.surfaceSecondary,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
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 React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { View, Text, StyleSheet, Animated } from 'react-native';
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Animated,
|
||||||
|
TouchableOpacity,
|
||||||
|
Dimensions,
|
||||||
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { AppColors, BorderRadius, FontSizes, Spacing, FontWeights, Shadows } from '@/constants/theme';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import {
|
||||||
|
AppColors,
|
||||||
|
BorderRadius,
|
||||||
|
FontSizes,
|
||||||
|
FontWeights,
|
||||||
|
Spacing,
|
||||||
|
Shadows,
|
||||||
|
} from '@/constants/theme';
|
||||||
|
|
||||||
interface ToastProps {
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
|
|
||||||
|
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||||
|
|
||||||
|
interface ToastConfig {
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
duration?: number;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
show: (config: ToastConfig) => void;
|
||||||
|
success: (title: string, message?: string) => void;
|
||||||
|
error: (title: string, message?: string) => void;
|
||||||
|
info: (title: string, message?: string) => void;
|
||||||
|
hide: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastConfig = {
|
||||||
|
success: {
|
||||||
|
icon: 'checkmark-circle' as const,
|
||||||
|
color: AppColors.success,
|
||||||
|
bgColor: AppColors.successLight,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: 'close-circle' as const,
|
||||||
|
color: AppColors.error,
|
||||||
|
bgColor: AppColors.errorLight,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: 'information-circle' as const,
|
||||||
|
color: AppColors.info,
|
||||||
|
bgColor: AppColors.infoLight,
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: 'warning' as const,
|
||||||
|
color: AppColors.warning,
|
||||||
|
bgColor: AppColors.warningLight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ToastProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: ToastProviderProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [config, setConfig] = useState<ToastConfig | null>(null);
|
||||||
|
|
||||||
|
const translateY = useRef(new Animated.Value(-100)).current;
|
||||||
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const hide = useCallback(() => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(translateY, {
|
||||||
|
toValue: -100,
|
||||||
|
duration: 250,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 250,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
setVisible(false);
|
||||||
|
setConfig(null);
|
||||||
|
});
|
||||||
|
}, [translateY, opacity]);
|
||||||
|
|
||||||
|
const show = useCallback((newConfig: ToastConfig) => {
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(newConfig);
|
||||||
|
setVisible(true);
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.spring(translateY, {
|
||||||
|
toValue: 0,
|
||||||
|
tension: 80,
|
||||||
|
friction: 10,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
// Auto hide
|
||||||
|
const duration = newConfig.duration ?? 3000;
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
hide();
|
||||||
|
}, duration);
|
||||||
|
}, [translateY, opacity, hide]);
|
||||||
|
|
||||||
|
const success = useCallback((title: string, message?: string) => {
|
||||||
|
show({ type: 'success', title, message });
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
const error = useCallback((title: string, message?: string) => {
|
||||||
|
show({ type: 'error', title, message });
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
const info = useCallback((title: string, message?: string) => {
|
||||||
|
show({ type: 'info', title, message });
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const typeConfig = config ? toastConfig[config.type] : toastConfig.info;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ show, success, error, info, hide }}>
|
||||||
|
{children}
|
||||||
|
{visible && config && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
top: insets.top + Spacing.sm,
|
||||||
|
transform: [{ translateY }],
|
||||||
|
opacity,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
<View style={styles.toast}>
|
||||||
|
{/* Icon */}
|
||||||
|
<View style={[styles.iconContainer, { backgroundColor: typeConfig.bgColor }]}>
|
||||||
|
<Ionicons
|
||||||
|
name={typeConfig.icon}
|
||||||
|
size={24}
|
||||||
|
color={typeConfig.color}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title}>{config.title}</Text>
|
||||||
|
{config.message && (
|
||||||
|
<Text style={styles.message} numberOfLines={2}>{config.message}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action or Close */}
|
||||||
|
{config.action ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => {
|
||||||
|
config.action?.onPress();
|
||||||
|
hide();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.actionText}>{config.action.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity style={styles.closeButton} onPress={hide}>
|
||||||
|
<Ionicons name="close" size={20} color={AppColors.textMuted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy Toast component for backwards compatibility
|
||||||
|
interface LegacyToastProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
icon?: keyof typeof Ionicons.glyphMap;
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
@ -11,7 +223,7 @@ interface ToastProps {
|
|||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toast({ visible, message, icon = 'checkmark-circle', duration = 2000, onHide }: ToastProps) {
|
export function Toast({ visible, message, icon = 'checkmark-circle', duration = 2000, onHide }: LegacyToastProps) {
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
const translateY = useRef(new Animated.Value(20)).current;
|
const translateY = useRef(new Animated.Value(20)).current;
|
||||||
|
|
||||||
@ -54,23 +266,81 @@ export function Toast({ visible, message, icon = 'checkmark-circle', duration =
|
|||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.container,
|
styles.legacyContainer,
|
||||||
{
|
{
|
||||||
opacity: fadeAnim,
|
opacity: fadeAnim,
|
||||||
transform: [{ translateY }],
|
transform: [{ translateY }],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.iconContainer}>
|
<View style={styles.legacyIconContainer}>
|
||||||
<Ionicons name={icon} size={20} color={AppColors.white} />
|
<Ionicons name={icon} size={20} color={AppColors.white} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.message}>{message}</Text>
|
<Text style={styles.legacyMessage}>{message}</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
// New Toast Provider styles
|
||||||
container: {
|
container: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: Spacing.md,
|
||||||
|
right: Spacing.md,
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.xl,
|
||||||
|
padding: Spacing.md,
|
||||||
|
gap: Spacing.md,
|
||||||
|
...Shadows.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: AppColors.borderLight,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
fontWeight: FontWeights.semibold,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
paddingVertical: Spacing.sm,
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
|
backgroundColor: AppColors.primaryLighter,
|
||||||
|
borderRadius: BorderRadius.md,
|
||||||
|
},
|
||||||
|
actionText: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
fontWeight: FontWeights.semibold,
|
||||||
|
color: AppColors.primary,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: BorderRadius.md,
|
||||||
|
backgroundColor: AppColors.surfaceSecondary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
// Legacy Toast styles
|
||||||
|
legacyContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 100,
|
bottom: 100,
|
||||||
left: Spacing.xl,
|
left: Spacing.xl,
|
||||||
@ -84,7 +354,7 @@ const styles = StyleSheet.create({
|
|||||||
gap: Spacing.sm,
|
gap: Spacing.sm,
|
||||||
...Shadows.lg,
|
...Shadows.lg,
|
||||||
},
|
},
|
||||||
iconContainer: {
|
legacyIconContainer: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
@ -92,7 +362,7 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
message: {
|
legacyMessage: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.base,
|
||||||
fontWeight: FontWeights.medium,
|
fontWeight: FontWeights.medium,
|
||||||
|
|||||||
@ -50,6 +50,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
console.log(`[AuthContext] checkAuth: Checking token...`);
|
console.log(`[AuthContext] checkAuth: Checking token...`);
|
||||||
|
const token = await api.getToken();
|
||||||
|
console.log(`[AuthContext] checkAuth: Token exists=${!!token}, length=${token?.length || 0}`);
|
||||||
const isAuth = await api.isAuthenticated();
|
const isAuth = await api.isAuthenticated();
|
||||||
console.log(`[AuthContext] checkAuth: isAuth=${isAuth}`);
|
console.log(`[AuthContext] checkAuth: isAuth=${isAuth}`);
|
||||||
|
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@ -19,6 +19,7 @@
|
|||||||
"expo-av": "~16.0.8",
|
"expo-av": "~16.0.8",
|
||||||
"expo-build-properties": "~1.0.10",
|
"expo-build-properties": "~1.0.10",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-constants": "~18.0.12",
|
"expo-constants": "~18.0.12",
|
||||||
"expo-crypto": "~15.0.8",
|
"expo-crypto": "~15.0.8",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.10",
|
||||||
@ -10670,6 +10671,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-clipboard": {
|
||||||
|
"version": "8.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
|
||||||
|
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.12",
|
"version": "18.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz",
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
"expo-av": "~16.0.8",
|
"expo-av": "~16.0.8",
|
||||||
"expo-build-properties": "~1.0.10",
|
"expo-build-properties": "~1.0.10",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-constants": "~18.0.12",
|
"expo-constants": "~18.0.12",
|
||||||
"expo-crypto": "~15.0.8",
|
"expo-crypto": "~15.0.8",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.10",
|
||||||
|
|||||||
159
services/api.ts
159
services/api.ts
@ -494,6 +494,165 @@ class ApiService {
|
|||||||
deployment_id: deploymentId,
|
deployment_id: deploymentId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Invitations API ====================
|
||||||
|
|
||||||
|
// Send invitation to share access to a beneficiary
|
||||||
|
async sendInvitation(params: {
|
||||||
|
beneficiaryId: string;
|
||||||
|
email: string;
|
||||||
|
role: 'caretaker' | 'guardian';
|
||||||
|
label?: string;
|
||||||
|
}): Promise<ApiResponse<{ invitation: { id: string; status: string } }>> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WELLNUO_API_URL}/invitations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
beneficiaryId: params.beneficiaryId,
|
||||||
|
email: params.email,
|
||||||
|
role: params.role, // Backend expects 'caretaker' or 'guardian'
|
||||||
|
label: params.label,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return { data, ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: data.error || 'Failed to send invitation' },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: 'Network error. Please check your connection.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get invitations for a beneficiary
|
||||||
|
async getInvitations(beneficiaryId: string): Promise<ApiResponse<{
|
||||||
|
invitations: Array<{
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
label?: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}>
|
||||||
|
}>> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WELLNUO_API_URL}/invitations/beneficiary/${beneficiaryId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return { data, ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: data.error || 'Failed to get invitations' },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: 'Network error. Please check your connection.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update invitation role
|
||||||
|
async updateInvitation(invitationId: string, role: 'caretaker' | 'guardian'): Promise<ApiResponse<{ success: boolean }>> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WELLNUO_API_URL}/invitations/${invitationId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
role: role === 'guardian' ? 'owner' : 'viewer',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return { data, ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: data.error || 'Failed to update invitation' },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: 'Network error. Please check your connection.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete invitation
|
||||||
|
async deleteInvitation(invitationId: string): Promise<ApiResponse<{ success: boolean }>> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WELLNUO_API_URL}/invitations/${invitationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return { data, ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: data.error || 'Failed to delete invitation' },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: 'Network error. Please check your connection.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiService();
|
export const api = new ApiService();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user