diff --git a/src/app/(gifts)/detail-gift.tsx b/src/app/(gifts)/detail-gift.tsx index 4047d48..7378fcc 100644 --- a/src/app/(gifts)/detail-gift.tsx +++ b/src/app/(gifts)/detail-gift.tsx @@ -38,8 +38,16 @@ const DetailGiftScreen = () => { useEffect(() => { const fetchRecipientName = async () => { - const name = await findRecipientById(Number(recipient)); - setRecipientName(name); + try { + const recipientData = await findRecipientById(Number(recipient)); + if (recipientData) { + setRecipientName(recipientData.name); + } else { + console.error('Recipient not found'); + } + } catch (error) { + console.error('Failed to fetch recipient name:', error); + } }; fetchRecipientName(); diff --git a/src/app/(gifts)/edit-gift.tsx b/src/app/(gifts)/edit-gift.tsx index 1b71aa3..5a37686 100644 --- a/src/app/(gifts)/edit-gift.tsx +++ b/src/app/(gifts)/edit-gift.tsx @@ -78,10 +78,12 @@ const EditGiftScreen = () => { if (recipientData) { setRecipientName(recipientData.name); } else { - const fetchedRecipientName = await findRecipientById( - Number(recipient), - ); - setRecipientName(fetchedRecipientName); + const fetchedRecipient = await findRecipientById(Number(recipient)); + if (fetchedRecipient) { + setRecipientName(fetchedRecipient.name); + } else { + console.error('Recipient not found'); + } } } catch (error) { console.error('Failed to fetch recipient name:', error); diff --git a/src/app/(gifts)/index.tsx b/src/app/(gifts)/index.tsx index f5a3751..ddc128f 100644 --- a/src/app/(gifts)/index.tsx +++ b/src/app/(gifts)/index.tsx @@ -204,7 +204,7 @@ const HomeScreen = () => { {filteredGifts.map((gift) => { - if (!gift || !gift.id) { + if (!gift || !gift.id || typeof gift !== 'object') { console.error('Invalid gift data:', gift); return null; } @@ -213,12 +213,12 @@ const HomeScreen = () => { ); })} diff --git a/src/app/recipients/add-recipient.tsx b/src/app/recipients/add-recipient.tsx index 55705e4..3bb4131 100644 --- a/src/app/recipients/add-recipient.tsx +++ b/src/app/recipients/add-recipient.tsx @@ -1,170 +1,265 @@ -import React, { useState } from 'react'; -import { View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, Image } from 'react-native'; -import * as ImagePicker from 'expo-image-picker'; import { MaterialIcons } from '@expo/vector-icons'; +import * as ImagePicker from 'expo-image-picker'; +import { router } from 'expo-router'; +import React, { useState } from 'react'; +import { + Image, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { addRecipient } from '@/features/recipients/recipientService'; +import { uploadRecipientAvatar } from '@/services/uploadImage'; const AddRecipientScreen = () => { - const [image, setImage] = useState(null); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [budget, setBudget] = useState(''); - const [spent, setSpent] = useState(''); - - const pickImage = async () => { - // Request permission - const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (status !== 'granted') { - alert('Sorry, we need camera roll permissions!'); - return; - } - - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, - aspect: [4, 3], - quality: 1, - }); - - if (!result.canceled) { - setImage(result.assets[0].uri); - } - }; - - const handleSaveRecipient = () => { - const recipientData = { - image: image || '', - name, - description, - budget: parseFloat(budget), - spent: parseFloat(spent), - }; - console.log(recipientData); - // TODO: gửi recipientData lên server hoặc lưu local - }; - - return ( - - {/* Upload Image */} - - Profile Image - - {image ? ( - - ) : ( - Pick an Image - )} - - - - {/* Name */} - - Name - - - - {/* Description */} - - Description (optional) - - - - {/* Budget */} - - Budget - - - - {/* Spent */} - - Spent - - - - {/* Save Button */} - - Save Recipient - - - ); + const [image, setImage] = useState(null); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [budget, setBudget] = useState(''); + const [errors, setErrors] = useState<{ name: string; budget: string }>({ + name: '', + budget: '', + }); + const [isSaving, setIsSaving] = useState(false); + + const pickImage = async () => { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + alert('Sorry, we need camera roll permissions!'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [4, 3], + quality: 1, + }); + + if (!result.canceled) { + setImage(result.assets[0].uri); + } + }; + + const validateFields = () => { + const errors = { + name: name ? '' : 'Name is required', + budget: budget + ? isNaN(Number(budget)) || Number(budget) <= 0 + ? 'Budget must be a valid positive number' + : '' + : 'Budget is required', + }; + + setErrors(errors); + + return !Object.values(errors).some((error) => error !== ''); + }; + + const handleSaveRecipient = async () => { + if (!validateFields()) { + return; + } + + setIsSaving(true); + + try { + let uploadedImageUrl = image; + + if (image) { + uploadedImageUrl = await uploadRecipientAvatar(image); + } else { + uploadedImageUrl = + 'https://ylguuncnueronwhhdvbk.supabase.co/storage/v1/object/public/recipient-avatar//recipient-avatar-placeholder.jpg'; + } + + const recipientData = { + image: uploadedImageUrl, + name, + description, + budget: parseFloat(budget), + spent: 0, + }; + + await addRecipient(recipientData); + + alert('Recipient added successfully!'); + router.push('/recipients'); + } catch (error) { + console.error('Error saving recipient:', error); + alert('Failed to add recipient. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + return ( + + {/* Upload Image */} + + Profile Image + + {image ? ( + + ) : ( + Pick an Image + )} + + + + {/* Name */} + + + Name * + + { + setName(text); + if (errors.name) setErrors({ ...errors, name: '' }); + }} + /> + {errors.name ? ( + {errors.name} + ) : null} + + + {/* Description */} + + Description (optional) + + + + {/* Budget */} + + + Budget * + + { + setBudget(text); + if (errors.budget) setErrors({ ...errors, budget: '' }); + }} + keyboardType="numeric" + /> + {errors.budget ? ( + {errors.budget} + ) : null} + + + {/* Save Button */} + + + {isSaving ? 'Saving...' : 'Save Recipient'} + + + + ); }; const styles = StyleSheet.create({ - container: { - padding: 16, - backgroundColor: '#F5F5F5', - flexGrow: 1, - }, - inputGroup: { - marginBottom: 16, - }, - label: { - fontSize: 14, - color: '#666', - marginBottom: 8, - }, - input: { - borderWidth: 1, - borderColor: '#E0E0E0', - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 15, - backgroundColor: 'white', - color: '#333', - }, - imagePicker: { - borderWidth: 1, - borderColor: '#E0E0E0', - borderRadius: 8, - height: 180, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'white', - }, - pickImageText: { - color: '#666', - fontSize: 16, - }, - previewImage: { - width: '100%', - height: '100%', - borderRadius: 8, - }, - saveButton: { - backgroundColor: '#007AFF', - padding: 16, - borderRadius: 8, - alignItems: 'center', - marginTop: 24, - }, - saveButtonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, + container: { + padding: 16, + backgroundColor: '#F5F5F5', + flexGrow: 1, + }, + inputGroup: { + marginBottom: 16, + }, + label: { + fontSize: 14, + color: '#666', + marginBottom: 8, + }, + input: { + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + backgroundColor: 'white', + color: '#333', + }, + imagePicker: { + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 8, + height: 180, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'white', + }, + imagePickerCircle: { + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 90, + height: 180, + width: 180, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'white', + alignSelf: 'center', + }, + pickImageText: { + color: '#666', + fontSize: 16, + }, + previewImage: { + width: '100%', + height: '100%', + borderRadius: 8, + }, + previewImageCircle: { + width: '100%', + height: '100%', + borderRadius: 90, + }, + saveButton: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + alignItems: 'center', + marginTop: 24, + }, + saveButtonDisabled: { + backgroundColor: '#A0A0A0', + }, + saveButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, + errorText: { + color: 'red', + fontSize: 12, + marginTop: 4, + }, + required: { + color: 'red', + }, + inputError: { + borderColor: 'red', + }, }); export default AddRecipientScreen; diff --git a/src/app/recipients/detail-recipient.tsx b/src/app/recipients/detail-recipient.tsx index b44369f..aeb5955 100644 --- a/src/app/recipients/detail-recipient.tsx +++ b/src/app/recipients/detail-recipient.tsx @@ -1,138 +1,216 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; -import { Alert, Pressable, StyleSheet, Text, View, Image } from 'react-native'; -import { Recipient } from '@/models/Recipient'; +import { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Image, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; + +import Loading from '@/components/Loading'; +import { + deleteRecipient, + findRecipientById, +} from '@/features/recipients/recipientService'; +import { Recipient } from '@/features/recipients/types'; +import { formatPrice } from '@/utils/priceUtils'; const DetailRecipientScreen = () => { - const router = useRouter(); - const { id, image, name, budget, spent, description } = useLocalSearchParams() as unknown as Recipient; - - // Handle edit action - const handleEdit = () => { - router.push({ - pathname: '/recipients/edit-recipient', - params: { - id, - image, - name, - budget, - spent, - description, - }, - }); - }; - - // Handle delete action - const handleDelete = () => { - Alert.alert( - 'Confirm Delete', - `Are you sure you want to delete the recipient "${name}"?`, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: () => console.log(`Delete action triggered for ID: ${id}`), - }, - ] - ); - }; - - return ( - - - - {name} - Budget: ${budget} - Spent: ${spent} - - {description && ( - {description} - )} - - {/* Action Buttons: Edit and Delete */} - - - Delete - - - Edit - - - - ); + const router = useRouter(); + const { id } = useLocalSearchParams(); + const [recipient, setRecipient] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + const fetchRecipient = async () => { + try { + const data = await findRecipientById(Number(id)); + if (data && typeof data === 'object' && 'id' in data) { + setRecipient(data as Recipient); + } else { + throw new Error('Invalid recipient data'); + } + } catch (error) { + console.error('Failed to fetch recipient:', error); + Alert.alert('Error', 'Failed to load recipient details.'); + } finally { + setIsLoading(false); + } + }; + + fetchRecipient(); + }, [id]); + + const handleEdit = () => { + router.push({ + pathname: '/recipients/edit-recipient', + params: { id }, + }); + }; + + const handleDelete = () => { + if (!recipient) return; + Alert.alert( + 'Confirm Delete', + `Are you sure you want to delete the recipient "${recipient.name}"?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + setIsDeleting(true); + + await deleteRecipient(recipient.id); + + router.push('/recipients'); + + setTimeout(() => { + Alert.alert('Success', 'Recipient deleted successfully.'); + }, 500); + } catch (error) { + console.error('Failed to delete recipient:', error); + Alert.alert( + 'Error', + 'Failed to delete the recipient. Please try again.', + ); + } finally { + setIsDeleting(false); + } + }, + }, + ], + ); + }; + + if (isLoading) { + return ; + } + + if (!recipient) { + return ( + + Recipient not found. + + ); + } + + return ( + + + + {recipient.name} + Budget: {formatPrice(recipient.budget)} + Spent: {formatPrice(recipient.spent)} + + {recipient.description && ( + {recipient.description} + )} + + {/* Action Buttons: Edit and Delete */} + + + {isDeleting ? ( + + ) : ( + Delete + )} + + + Edit + + + + ); }; const styles = StyleSheet.create({ - container: { - flex: 1, - padding: 16, - backgroundColor: '#F8F9FA', - alignItems: 'center', - }, - image: { - width: 120, - height: 120, - borderRadius: 60, - marginBottom: 16, - }, - title: { - fontSize: 24, - fontWeight: '600', - color: '#333333', - }, - budget: { - fontSize: 18, - color: '#666666', - marginTop: 8, - }, - spent: { - fontSize: 18, - color: '#666666', - marginTop: 4, - }, - description: { - fontSize: 16, - color: '#666666', - marginTop: 12, - textAlign: 'center', - }, - actions: { - flexDirection: 'row', - justifyContent: 'space-between', - width: '100%', - marginTop: 24, - }, - editButton: { - flex: 1, - backgroundColor: '#ffa200', - paddingVertical: 14, - borderRadius: 8, - alignItems: 'center', - marginLeft: 8, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 6, - elevation: 5, - }, - deleteButton: { - flex: 1, - backgroundColor: '#FF4D4F', - paddingVertical: 14, - borderRadius: 8, - alignItems: 'center', - marginRight: 8, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 6, - elevation: 5, - }, - buttonText: { - color: '#FFFFFF', - fontSize: 16, - fontWeight: 'bold', - }, + container: { + flex: 1, + padding: 16, + backgroundColor: '#F8F9FA', + alignItems: 'center', + }, + image: { + width: 120, + height: 120, + borderRadius: 60, + marginBottom: 16, + }, + title: { + fontSize: 24, + fontWeight: '600', + color: '#333333', + }, + budget: { + fontSize: 18, + color: '#666666', + marginTop: 8, + }, + spent: { + fontSize: 18, + color: '#666666', + marginTop: 4, + }, + description: { + fontSize: 16, + color: '#666666', + marginTop: 12, + textAlign: 'center', + }, + actions: { + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', + marginTop: 24, + }, + editButton: { + flex: 1, + backgroundColor: '#ffa200', + paddingVertical: 14, + borderRadius: 8, + alignItems: 'center', + marginLeft: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 6, + elevation: 5, + }, + deleteButton: { + flex: 1, + backgroundColor: '#FF4D4F', + paddingVertical: 14, + borderRadius: 8, + alignItems: 'center', + marginRight: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 6, + elevation: 5, + }, + buttonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: 'bold', + }, + disabledButton: { + opacity: 0.6, + }, + errorText: { + fontSize: 18, + color: 'red', + textAlign: 'center', + }, }); export default DetailRecipientScreen; diff --git a/src/app/recipients/edit-recipient.tsx b/src/app/recipients/edit-recipient.tsx index f7d9e85..d8b5d8d 100644 --- a/src/app/recipients/edit-recipient.tsx +++ b/src/app/recipients/edit-recipient.tsx @@ -1,189 +1,304 @@ -import React, { useState, useEffect } from 'react'; -import { View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, Image } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; -import { Recipient } from '@/models/Recipient'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useEffect, useState } from 'react'; import { - useNavigation, - useRoute as useRouteAlias, -} from '@react-navigation/native'; + Image, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import Loading from '@/components/Loading'; +import { + findRecipientById, + updateRecipient, +} from '@/features/recipients/recipientService'; +import { Recipient } from '@/features/recipients/types'; +import { updateImageInBucket } from '@/services/updateImage'; const EditRecipientScreen = () => { - const navigation = useNavigation(); - const route = useRouteAlias(); - const recipient = route.params as Recipient; - const [image, setImage] = useState(recipient.image); - const [name, setName] = useState(recipient.name); - const [description, setDescription] = useState(recipient.description || ''); - const [budget, setBudget] = useState(recipient.budget.toString()); - const [spent, setSpent] = useState(recipient.spent.toString()); - - useEffect(() => { - if (recipient) { - setImage(recipient.image); - setName(recipient.name); - setDescription(recipient.description || ''); - setBudget(recipient.budget.toString()); - setSpent(recipient.spent.toString()); - } - }, [recipient]); - - const pickImage = async () => { - // Request permission - const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (status !== 'granted') { - alert('Sorry, we need camera roll permissions!'); - return; - } - - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, - aspect: [4, 3], - quality: 1, - }); - - if (!result.canceled) { - setImage(result.assets[0].uri); - } - }; - - const handleSaveRecipient = () => { - const updatedRecipient = { - image: image || '', - name, - description, - budget: parseFloat(budget), - spent: parseFloat(spent), - }; - console.log(updatedRecipient); - // TODO: gửi updatedRecipient lên server hoặc lưu vào cơ sở dữ liệu - // Điều hướng trở lại màn hình trước - navigation.goBack(); - }; - - return ( - - {/* Upload Image */} - - Profile Image - - {image ? ( - - ) : ( - Pick an Image - )} - - - - {/* Name */} - - Name - - - - {/* Description */} - - Description (optional) - - - - {/* Budget */} - - Budget - - - - {/* Spent */} - - Spent - - - - {/* Save Button */} - - Save Recipient - - - ); + const router = useRouter(); + const recipient = useLocalSearchParams() as unknown as Recipient; + const [image, setImage] = useState(recipient.image); + const [name, setName] = useState(recipient.name); + const [description, setDescription] = useState(recipient.description || ''); + const [budget, setBudget] = useState(recipient.budget.toString()); + const [spent, setSpent] = useState(recipient.spent.toString()); + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchRecipient = async () => { + try { + const data = await findRecipientById(Number(recipient.id)); + if (data && typeof data === 'object') { + const recipientData = data as Recipient; + setImage(recipientData.image || null); + setName(recipientData.name || ''); + setDescription(recipientData.description || ''); + setBudget(recipientData.budget?.toString() || ''); + setSpent(recipientData.spent?.toString() || ''); + } else { + throw new Error('Recipient not found'); + } + } catch (error) { + console.error('Failed to fetch recipient:', error); + alert('Failed to load recipient details.'); + } finally { + setIsLoading(false); + } + }; + + fetchRecipient(); + }, [recipient.id]); + + if (isLoading) { + return ; + } + + const pickImage = async () => { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + alert('Sorry, we need camera roll permissions!'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [4, 3], + quality: 1, + }); + + if (!result.canceled) { + setImage(result.assets[0].uri); + } + }; + + const validateFields = () => { + const errors = { + name: name ? '' : 'Name is required', + budget: budget + ? isNaN(Number(budget)) || Number(budget) <= 0 + ? 'Budget must be a valid positive number' + : Number(budget) < Number(spent) + ? 'Budget cannot be less than spent amount' + : '' + : 'Budget is required', + }; + + setErrors(errors); + + return !Object.values(errors).some((error) => error !== ''); + }; + + const handleSaveRecipient = async () => { + if (!validateFields()) { + return; + } + + setIsSaving(true); + + try { + let updatedImageUrl = recipient.image; + + if (image && image !== recipient.image) { + updatedImageUrl = await updateImageInBucket( + recipient.image, + image, + 'recipient-avatar', + ); + } + + const updatedRecipient = { + image: updatedImageUrl, + name, + description, + budget: parseFloat(budget), + }; + + await updateRecipient(recipient.id, updatedRecipient); + + alert('Recipient updated successfully!'); + router.push({ + pathname: '/recipients/detail-recipient', + params: { id: recipient.id }, + }); + } catch (error) { + console.error('Error updating recipient:', error); + alert('Failed to update recipient. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + return ( + + {/* Upload Image */} + + Profile Image + + {image ? ( + + ) : ( + Pick an Image + )} + + + + {/* Name */} + + + Name * + + { + setName(text); + if (errors.name) setErrors({ ...errors, name: '' }); + }} + /> + {errors.name ? ( + {errors.name} + ) : null} + + + {/* Description */} + + Description (optional) + + + + {/* Budget */} + + + Budget * + + { + setBudget(text); + if (errors.budget) setErrors({ ...errors, budget: '' }); + }} + keyboardType="numeric" + /> + {errors.budget ? ( + {errors.budget} + ) : null} + + + {/* Save Button */} + + + {isSaving ? 'Saving...' : 'Save Recipient'} + + + + ); }; const styles = StyleSheet.create({ - container: { - padding: 16, - backgroundColor: '#F5F5F5', - flexGrow: 1, - }, - inputGroup: { - marginBottom: 16, - }, - label: { - fontSize: 14, - color: '#666', - marginBottom: 8, - }, - input: { - borderWidth: 1, - borderColor: '#E0E0E0', - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 15, - backgroundColor: 'white', - color: '#333', - }, - imagePicker: { - borderWidth: 1, - borderColor: '#E0E0E0', - borderRadius: 8, - height: 180, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'white', - }, - pickImageText: { - color: '#666', - fontSize: 16, - }, - previewImage: { - width: '100%', - height: '100%', - borderRadius: 8, - }, - saveButton: { - backgroundColor: '#007AFF', - padding: 16, - borderRadius: 8, - alignItems: 'center', - marginTop: 24, - }, - saveButtonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, + container: { + padding: 16, + backgroundColor: '#F5F5F5', + flexGrow: 1, + }, + inputGroup: { + marginBottom: 16, + }, + label: { + fontSize: 14, + color: '#666', + marginBottom: 8, + }, + input: { + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + backgroundColor: 'white', + color: '#333', + }, + imagePicker: { + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 8, + height: 180, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'white', + }, + imagePickerCircle: { + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 90, + height: 180, + width: 180, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'white', + alignSelf: 'center', + }, + pickImageText: { + color: '#666', + fontSize: 16, + }, + previewImage: { + width: '100%', + height: '100%', + borderRadius: 8, + }, + previewImageCircle: { + width: '100%', + height: '100%', + borderRadius: 90, + }, + saveButton: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + alignItems: 'center', + marginTop: 24, + }, + saveButtonDisabled: { + backgroundColor: '#A0A0A0', + }, + saveButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, + errorText: { + color: 'red', + fontSize: 12, + marginTop: 4, + }, + required: { + color: 'red', + }, + inputError: { + borderColor: 'red', + }, }); export default EditRecipientScreen; diff --git a/src/app/recipients/index.tsx b/src/app/recipients/index.tsx index 6393349..3e51852 100644 --- a/src/app/recipients/index.tsx +++ b/src/app/recipients/index.tsx @@ -1,112 +1,205 @@ import { Ionicons } from '@expo/vector-icons'; -import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; -import { Recipient } from '@/models/Recipient'; +import { useEffect, useState } from 'react'; +import { + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import RecipientCard from '@/components/utils/RecipientCard'; +import { fetchRecipients } from '@/features/recipients/recipientService'; +import { Recipient } from '@/features/recipients/types'; const AllRecipientsScreen = () => { - const router = useRouter(); - - const recipients: Recipient[] = [ - { id: '1', image: 'https://img.freepik.com/premium-vector/cute-boy-smiling-cartoon-kawaii-boy-illustration-boy-avatar-happy-kid_1001605-3445.jpg', name: 'Alex', description: 'Loves outdoor activities and sports.', budget: 2000, spent: 1500 }, - { id: '2', image: 'https://static.vecteezy.com/system/resources/previews/004/899/833/non_2x/beautiful-girl-with-blue-hair-avatar-of-woman-for-social-network-vector.jpg', name: 'Emily', description: 'Enjoys painting and creative arts.', budget: 2200, spent: 1600 }, - { id: '3', image: 'https://img.freepik.com/premium-vector/boy-with-blue-hoodie-blue-hoodie-with-hoodie-it_1230457-42660.jpg', name: 'Michael', description: 'A tech enthusiast and gamer.', budget: 3000, spent: 1500 }, - { id: '4', image: 'https://img.freepik.com/premium-vector/boy-with-hoodie-that-says-hes-boy_1230457-43316.jpg', name: 'Malow', description: 'Passionate about music and instruments.', budget: 1800, spent: 1200 }, - ]; - - const handleAddRecipient = () => { - router.push('/recipients/add-recipient'); - }; - - return ( - - - - - - - - Add - - - - All Recipients - - - {recipients.map((recipient) => ( - ([]); + const [sortField, setSortField] = useState(null); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + const [searchQuery, setSearchQuery] = useState(''); + + const filteredRecipients = recipients.filter((recipient) => + recipient.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + useEffect(() => { + const loadRecipients = async () => { + try { + const data = await fetchRecipients(); + setRecipients(data); + } catch (error) { + console.error('Failed to fetch recipients:', error); + } + }; + + loadRecipients(); + }, []); + + const handleAddRecipient = () => { + router.push('/recipients/add-recipient'); + }; + + const handleSort = (field: keyof Recipient) => { + const newOrder = + sortField === field && sortOrder === 'asc' ? 'desc' : 'asc'; + setSortField(field); + setSortOrder(newOrder); + + const sortedRecipients = [...recipients].sort((a, b) => { + if (newOrder === 'asc') { + return (a[field] ?? '') > (b[field] ?? '') ? 1 : -1; + } else { + return (a[field] ?? '') < (b[field] ?? '') ? 1 : -1; + } + }); + + setRecipients(sortedRecipients); + }; + + const handleSearch = (query: string) => { + setSearchQuery(query); + }; + + return ( + + + + + + + + Add + + + + All Recipients + + + {['name', 'budget', 'spent'].map((field) => ( + handleSort(field as keyof Recipient)} + > + + {field.charAt(0).toUpperCase() + field.slice(1)} + + + + ))} + + + + {filteredRecipients.map((recipient) => ( + - ))} - - - ); + budget={recipient.budget} + spent={recipient.spent} + /> + ))} + + + ); }; const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#F8F9FA', - }, - header: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - gap: 12, - }, - searchContainer: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'white', - borderRadius: 8, - paddingHorizontal: 12, - }, - searchIcon: { - marginRight: 8, - }, - searchInput: { - flex: 1, - height: 40, - fontSize: 16, - color: '#333333', - }, - addButton: { - backgroundColor: '#4ADE80', - paddingHorizontal: 20, - paddingVertical: 10, - borderRadius: 8, - }, - addButtonText: { - color: 'white', - fontWeight: '600', - }, - title: { - fontSize: 24, - fontWeight: '600', - paddingHorizontal: 16, - marginBottom: 16, - color: '#33333', - }, - scrollView: { - flex: 1, - }, + container: { + flex: 1, + backgroundColor: '#F8F9FA', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + gap: 12, + }, + searchContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'white', + borderRadius: 8, + paddingHorizontal: 12, + }, + searchIcon: { + marginRight: 8, + }, + searchInput: { + flex: 1, + height: 40, + fontSize: 16, + color: '#333333', + }, + addButton: { + backgroundColor: '#4ADE80', + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 8, + }, + addButtonText: { + color: 'white', + fontWeight: '600', + }, + title: { + fontSize: 24, + fontWeight: '600', + paddingHorizontal: 16, + marginBottom: 16, + color: '#33333', + }, + sortButtonsContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + marginBottom: 16, + }, + sortButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + borderRadius: 8, + backgroundColor: '#E5E7EB', + }, + activeSortButton: { + backgroundColor: '#D1FAE5', + }, + sortButtonText: { + marginRight: 4, + fontSize: 16, + color: '#333333', + }, + scrollView: { + flex: 1, + }, }); export default AllRecipientsScreen; diff --git a/src/components/utils/FilterTabs.tsx b/src/components/utils/FilterTabs.tsx index 8b14012..7aafc30 100644 --- a/src/components/utils/FilterTabs.tsx +++ b/src/components/utils/FilterTabs.tsx @@ -187,4 +187,15 @@ const styles = StyleSheet.create({ activeOptionText: { color: '#4B6BFB', }, + searchInput: { + height: 50, + paddingVertical: 12, + paddingHorizontal: 16, + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 8, + fontSize: 16, + color: '#333', + backgroundColor: 'white', + }, }); diff --git a/src/components/utils/GiftCard.tsx b/src/components/utils/GiftCard.tsx index 394c354..4e9d085 100644 --- a/src/components/utils/GiftCard.tsx +++ b/src/components/utils/GiftCard.tsx @@ -25,7 +25,7 @@ export default function GiftCard({ useEffect(() => { const fetchRecipientName = async () => { const name = await findRecipientById(Number(recipient)); - setRecipientName(name); + setRecipientName(name?.name!); }; fetchRecipientName(); diff --git a/src/components/utils/RecipientCard.tsx b/src/components/utils/RecipientCard.tsx index bfc0b35..3431fea 100644 --- a/src/components/utils/RecipientCard.tsx +++ b/src/components/utils/RecipientCard.tsx @@ -1,86 +1,88 @@ import { useRouter } from 'expo-router'; import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; -import { Recipient } from '@/models/Recipient'; + +import { Recipient } from '@/features/recipients/types'; export default function RecipientCard({ - id, - image, - name, + id, + image, + name, description, - budget, - spent, + budget, + spent, }: Recipient) { - const router = useRouter(); + const router = useRouter(); - const handlePress = () => { - router.push({ - pathname: '/recipients/detail-recipient', - params: { id, image, name, description, budget, spent }, - }); - }; + const handlePress = () => { + router.push({ + pathname: '/recipients/detail-recipient', + params: { id }, + }); + }; - return ( - - - - {name} - Budget: ${budget} - Spent: ${spent} - - - ); + return ( + + + + {name} + {description && {description}} + Budget: ${budget} + Spent: ${spent} + + + ); } const styles = StyleSheet.create({ - card: { - backgroundColor: 'white', - borderRadius: 12, - marginHorizontal: 16, - marginVertical: 8, - flexDirection: 'row', - padding: 12, - shadowColor: '#000000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - alignItems: 'center', - }, - image: { - width: 60, - height: 60, - borderRadius: 30, - }, - content: { - marginLeft: 12, - flex: 1, - }, - name: { - fontSize: 16, - fontWeight: '600', - color: '#333333', - }, + card: { + backgroundColor: 'white', + borderRadius: 12, + marginHorizontal: 16, + marginVertical: 8, + flexDirection: 'row', + padding: 12, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + alignItems: 'center', + }, + image: { + width: 60, + height: 60, + borderRadius: 30, + }, + content: { + marginLeft: 12, + flex: 1, + }, + name: { + fontSize: 16, + fontWeight: '600', + color: '#333333', + }, description: { fontSize: 14, color: '#666666', marginTop: 4, }, - budget: { - fontSize: 14, - color: '#666666', - marginTop: 4, - }, - spent: { - fontSize: 14, - color: '#666666', - marginTop: 4, - }, - actions: { - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'space-evenly', - }, - actionButton: { - marginVertical: 8, - }, + budget: { + fontSize: 14, + color: '#666666', + marginTop: 4, + }, + spent: { + fontSize: 14, + color: '#666666', + marginTop: 4, + }, + actions: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'space-evenly', + }, + actionButton: { + marginVertical: 8, + }, }); diff --git a/src/features/recipients/recipientService.ts b/src/features/recipients/recipientService.ts index 41492e1..968184f 100644 --- a/src/features/recipients/recipientService.ts +++ b/src/features/recipients/recipientService.ts @@ -38,10 +38,12 @@ export const updateRecipient = async ( return data as Recipient; }; -export const findRecipientById = async (id: number): Promise => { +export const findRecipientById = async ( + id: number, +): Promise => { const { data, error } = await supabase .from('recipients') - .select('name') + .select('*') .eq('id', id) .single(); @@ -50,5 +52,5 @@ export const findRecipientById = async (id: number): Promise => { return null; } - return data?.name || null; + return data as Recipient; }; diff --git a/src/services/deleteImage.ts b/src/services/deleteImage.ts index 8249c22..2925af7 100644 --- a/src/services/deleteImage.ts +++ b/src/services/deleteImage.ts @@ -3,12 +3,12 @@ import supabase from '@/services/supabaseClient'; /** * Deletes an image from Supabase storage based on its public URL. * @param imageUrl - The public URL of the image to delete. - * @param bucketName - The name of the Supabase storage bucket (default: 'gift-thumbnail'). + * @param bucketName - The name of the Supabase storage bucket. * @throws Will throw an error if the deletion fails. */ -export const deleteGiftThumbnail = async ( +export const deleteImageFromBucket = async ( imageUrl: string, - bucketName: string = 'gift-thumbnail', + bucketName: string, ): Promise => { try { const fileName = imageUrl.split('/').pop(); @@ -30,3 +30,27 @@ export const deleteGiftThumbnail = async ( throw new Error('Failed to delete image'); } }; + +/** + * Deletes a gift thumbnail from Supabase storage. + * @param imageUrl - The public URL of the gift thumbnail to delete. + * @param bucketName - The name of the Supabase storage bucket (default: 'gift-thumbnail'). + */ +export const deleteGiftThumbnail = async ( + imageUrl: string, + bucketName: string = 'gift-thumbnail', +): Promise => { + return deleteImageFromBucket(imageUrl, bucketName); +}; + +/** + * Deletes a recipient avatar from Supabase storage. + * @param imageUrl - The public URL of the recipient avatar to delete. + * @param bucketName - The name of the Supabase storage bucket (default: 'recipient-avatar'). + */ +export const deleteRecipientAvatar = async ( + imageUrl: string, + bucketName: string = 'recipient-avatar', +): Promise => { + return deleteImageFromBucket(imageUrl, bucketName); +}; diff --git a/src/services/updateImage.ts b/src/services/updateImage.ts index c644a7b..12ddda1 100644 --- a/src/services/updateImage.ts +++ b/src/services/updateImage.ts @@ -1,24 +1,24 @@ -import { deleteGiftThumbnail } from '@/services/deleteImage'; -import { uploadGiftThumbnail } from '@/services/uploadImage'; +import { deleteImageFromBucket } from '@/services/deleteImage'; +import { uploadFileToBucket } from '@/services/uploadImage'; /** * Updates an image in Supabase storage by deleting the old image and uploading a new one. * @param oldImageUrl - The public URL of the old image to delete. * @param newImageUri - The URI of the new image to upload. - * @param bucketName - The name of the Supabase storage bucket (default: 'gift-thumbnail'). + * @param bucketName - The name of the Supabase storage bucket. * @returns The public URL of the new image. */ -export const updateGiftThumbnail = async ( +export const updateImageInBucket = async ( oldImageUrl: string, newImageUri: string, - bucketName: string = 'gift-thumbnail', + bucketName: string, ): Promise => { try { if (oldImageUrl) { - await deleteGiftThumbnail(oldImageUrl, bucketName); + await deleteImageFromBucket(oldImageUrl, bucketName); } - const newImageUrl = await uploadGiftThumbnail(newImageUri, bucketName); + const newImageUrl = await uploadFileToBucket(newImageUri, bucketName); return newImageUrl; } catch (error) { @@ -26,3 +26,33 @@ export const updateGiftThumbnail = async ( throw new Error('Failed to update image'); } }; + +/** + * Updates a gift thumbnail in Supabase storage. + * @param oldImageUrl - The public URL of the old gift thumbnail to delete. + * @param newImageUri - The URI of the new gift thumbnail to upload. + * @param bucketName - The name of the Supabase storage bucket (default: 'gift-thumbnail'). + * @returns The public URL of the new gift thumbnail. + */ +export const updateGiftThumbnail = async ( + oldImageUrl: string, + newImageUri: string, + bucketName: string = 'gift-thumbnail', +): Promise => { + return updateImageInBucket(oldImageUrl, newImageUri, bucketName); +}; + +/** + * Updates a recipient avatar in Supabase storage. + * @param oldImageUrl - The public URL of the old recipient avatar to delete. + * @param newImageUri - The URI of the new recipient avatar to upload. + * @param bucketName - The name of the Supabase storage bucket (default: 'recipient-avatar'). + * @returns The public URL of the new recipient avatar. + */ +export const updateRecipientAvatar = async ( + oldImageUrl: string, + newImageUri: string, + bucketName: string = 'recipient-avatar', +): Promise => { + return updateImageInBucket(oldImageUrl, newImageUri, bucketName); +}; diff --git a/src/services/uploadImage.ts b/src/services/uploadImage.ts index b7ad73e..3d85335 100644 --- a/src/services/uploadImage.ts +++ b/src/services/uploadImage.ts @@ -2,9 +2,10 @@ import * as FileSystem from 'expo-file-system'; import supabase from '@/services/supabaseClient'; -export const uploadGiftThumbnail = async ( +export const uploadFileToBucket = async ( uri: string, - bucketName: string = 'gift-thumbnail', + bucketName: string, + contentType: string = 'image/jpeg', ): Promise => { try { const fileName = uri.split('/').pop(); @@ -24,7 +25,7 @@ export const uploadGiftThumbnail = async ( const { data, error } = await supabase.storage .from(bucketName) .upload(fileName, arrayBuffer, { - contentType: 'image/jpeg', + contentType, cacheControl: '3600', upsert: true, }); @@ -45,7 +46,21 @@ export const uploadGiftThumbnail = async ( return publicUrlData.publicUrl; } catch (error) { - console.error('Error uploading image:', error); - throw new Error('Failed to upload image'); + console.error('Error uploading file:', error); + throw new Error('Failed to upload file'); } }; + +export const uploadGiftThumbnail = async ( + uri: string, + bucketName: string = 'gift-thumbnail', +): Promise => { + return uploadFileToBucket(uri, bucketName); +}; + +export const uploadRecipientAvatar = async ( + uri: string, + bucketName: string = 'recipient-avatar', +): Promise => { + return uploadFileToBucket(uri, bucketName); +};