diff --git a/.gitignore b/.gitignore index ec8a36a..dae9b76 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ npm-debug.* *.mobileprovision *.orig.* web-build/ +.env +.env* # macOS .DS_Store diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index e69de29..c5a718d 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import { StyleSheet } from 'react-native'; +import { ThemedView } from '@/components/theme/Theme'; +import UserInfo from '@/components/home/UserInfo'; +import Relationship from '@/components/home/Relationship'; + +const IndexScreen = () => { + const [pfpUrl, setPfpUrl] = useState(null); + const handlePfpUpdate = (newPfpUrl: string) => { + setPfpUrl(newPfpUrl); + }; + return ( + + + + + + + ); +}; +export default IndexScreen; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + }, + footerContainer: { + flex: 1 / 3, + alignItems: 'center', + }, +}); diff --git a/app/(tabs)/messages.tsx b/app/(tabs)/messages.tsx index c1e21de..861d40d 100644 --- a/app/(tabs)/messages.tsx +++ b/app/(tabs)/messages.tsx @@ -13,7 +13,7 @@ import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import AccessoryBar from '@/components/chat/AccessoryBar'; import CustomActions from '@/components/chat/CustomActions'; import CustomView from '@/components/chat/CustomView'; -import NavBar from '@/components/chat/NavBar'; +//import NavBar from '@/components/chat/NavBar'; import earlierMessages from '@/components/chat/data/earlierMessages'; import messages from '@/components/chat/data/messages'; import * as Clipboard from 'expo-clipboard'; @@ -22,7 +22,6 @@ import { GCState, GCStateAction, ActionKind, - GCMessage } from '@/constants/Types'; const tempUser: GCUser = { @@ -71,8 +70,193 @@ const MessagesScreen = () => { isTyping: false, }) - const onSend = useCallback((messages: GCMessage[]) => { + const onSend = useCallback((messages: any[]) => { const sentMessages = [{ ...messages[0], sent: true, received: true }] + const newMessages = GiftedChat.append( + state.messages, sentMessages, Platform.OS !== 'web' + ); + dispatch({ type: ActionKind.SEND_MESSAGE, payload: newMessages }); + }, [dispatch, state.messages]); + const onLoadEarlier = useCallback(() => { + dispatch({ type: ActionKind.LOAD_EARLIER_START }); + setTimeout(() => { + const newMessages = GiftedChat.prepend( + state.messages, earlierMessages() as IMessage[], Platform.OS !== 'web' + ); + dispatch({ type: ActionKind.LOAD_EARLIER_MESSAGES, payload: newMessages }) + }, 1500) // simulating network + }, [dispatch, state.messages]); + + const parsePatterns = useCallback(() => { + return [ + { + pattern: /#(\w+)/g, + style: { textDecorationLine: 'underline', color: 'darkorange' }, + onPress: () => Linking.openURL('https://www.gbrown.org'), + }, + ] + }, []); + + const onLongPressAvatar = useCallback((pressedUser: any) => { + Alert.alert(JSON.stringify(pressedUser)) + }, []); + + const onPressAvatar = useCallback(() => { + Alert.alert('Pressed avatar!') + }, []); + + const handleLongPress = useCallback((context: unknown, currentMessage: object) => { + if (!currentMessage.text) return; + const options = [ + 'Copy text', + 'Cancel', + ] + const cancelButtonIndex = options.length - 1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context as any).actionSheet().showActionSheetWithOptions( + { options, cancelButtonIndex }, + (buttonIndex: number) => { + switch (buttonIndex) { + case 0: + Clipboard.setStringAsync(currentMessage.text); + break; + default: break; + } + } + ) + }, []); + + const onQuickReply = useCallback((replies: any[]) => { + const createdAt = new Date(); + if (replies.length === 1) + onSend([ + { + createdAt, + _id: Math.round(Math.random() * 1000000), + text: replies[0].title, + tempUser, + }, + ]); + else if (replies.length > 1) + onSend([ + { + createdAt, + _id: Math.round(Math.random() * 1000000), + text: replies.map(reply => reply.title).join(', '), + tempUser, + }, + ]); + else console.warn('replies param is not set correctly'); + }, []); + + const renderQuickReplySend = useCallback(() => { + return {'custom send =>'} + }, []); + + const setIsTyping = useCallback((isTyping: boolean) => { + dispatch({ type: ActionKind.SET_IS_TYPING, payload: isTyping }); + }, [dispatch]); + + const onSendFromUser = useCallback((messages: IMessage[] = []) => { + const createdAt = new Date(); + const messagesToUpload = messages.map(message => ({ + ...message, tempUser, createdAt, id: Math.round(Math.random() * 1000000), + })); + onSend(messagesToUpload); + }, [onSend]); + + const renderAccessory = useCallback(() => { + return ( + setIsTyping(!state.isTyping)} + /> + ); + }, [onSendFromUser, setIsTyping, state.isTyping]); + + const renderCustomActions = useCallback( + props => + Platform.OS === 'web' ? null : ( + + ), + [onSendFromUser] + ); + + const renderSystemMessage = useCallback(props => { + return ( + + ); + }, []); + + const renderCustomView = useCallback(props => { + return + }, []); + + const renderSend = useCallback((props: SendProps) => { + return ( + + + + ); + }, []); + return ( + + + + + + ) }; -export default MessagesScreen; + +const ChatWrapper = () => { + return ( + + ); +}; + +export default ChatWrapper; + +const styles = StyleSheet.create({ + fill: { + flex: 1, + }, +}); diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index e69de29..5258bc2 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -0,0 +1,25 @@ +import { StyleSheet } from "react-native"; +import { ThemedText, ThemedView } from "@/components/theme/Theme"; + +const Settings = () => { + return ( + + + Settings + + + ); +}; + +export default Settings; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + text: { + fontSize: 24, + }, +}); diff --git a/components/auth/SignInScreen.tsx b/components/auth/SignInScreen.tsx index 51c745a..d92000d 100644 --- a/components/auth/SignInScreen.tsx +++ b/components/auth/SignInScreen.tsx @@ -1,14 +1,19 @@ -import React from "react"; -import * as AppleAuthentication from "expo-apple-authentication"; -import { StyleSheet, Alert } from "react-native"; -import { ThemedView } from "@/components/theme/Theme"; -import { useColorScheme } from "@/hooks/useColorScheme"; -import * as Notifications from "expo-notifications"; -import Constants from "expo-constants"; -import { saveUser, saveInitialData } from "@/components/services/SecureStore"; -import type { InitialData, User } from "@/constants/Types"; +import React from 'react'; +import * as AppleAuthentication from 'expo-apple-authentication'; +import { StyleSheet, Alert } from 'react-native'; +import { ThemedView } from '@/components/theme/Theme'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import * as Notifications from 'expo-notifications'; +import Constants from 'expo-constants'; +import { saveUser, saveInitialData } from '@/components/services/SecureStore'; +import { + getInitialDataByAppleId, + createUser, + updatePushToken +} from '@/constants/APIs'; +import type { InitialData, User } from '@/constants/Types'; -const SignInScreen({onSignIn}: {onSignIn: () => void}) => { +const SignInScreen = ({onSignIn}: {onSignIn: () => void}) => { const scheme = useColorScheme() ?? 'dark'; const handleAppleSignIn = async () => { @@ -30,17 +35,10 @@ const SignInScreen({onSignIn}: {onSignIn: () => void}) => { credential.fullName?.familyName, pushToken ); - const initialData = await - fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/users/getUserByAppleId` + - `?appleId=${credential.user}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': process.env.NEXT_PUBLIC_API_KEY ?? '', - }, - }); - console.log(initialData); - if (initialData.status === 404 || !initialData.ok) { + const initialDataResponse = await getInitialDataByAppleId(credential.user); + + console.log(initialDataResponse); + if (initialDataResponse.status === 404 || !initialDataResponse.ok) { if (!credential.user || !credential.email || !credential.fullName?.givenName || !credential.fullName?.familyName || !pushToken || credential.email.length === 0) { @@ -55,62 +53,30 @@ const SignInScreen({onSignIn}: {onSignIn: () => void}) => { 'save in the database.' ); } - - const userResponse = await - fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/users/createUser`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': process.env.NEXT_PUBLIC_API_KEY ?? '', - }, - body: JSON.stringify({ - appleId: credential.user, - email: credential.email, - fullName: - `${credential.fullName?.givenName} ${credential.fullName?.familyName}`, - pushToken: pushToken, - }), - }); - if (!userResponse.ok) { - const errorBody = await userResponse.text(); - console.error( - 'API error: No user returned: ', - userResponse.status, errorBody - ); - throw new Error(`Failed to create user: ${userResponse.status} ${errorBody}`); - } - const user: User = await userResponse.json() as User; + const user: User = await createUser( + credential.user, + credential.email, + credential.fullName?.givenName + credential.fullName?.familyName, + pushToken.data + ) as User; await saveUser(user); - } else if (initialData.ok) { - const allData: InitialData = await initialData.json() as InitialData; + } else if (initialDataResponse.ok) { + const initialData: InitialData = await initialDataResponse.json() as InitialData; console.log('Existing user found! Saving data...'); - if (allData.user.pushToken !== pushToken.data) { - const updatePushTokenResponse = - await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/users/updatePushToken`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': process.env.NEXT_PUBLIC_API_KEY ?? '', - }, - body: JSON.stringify({ - userId: allData.user.id, - pushToken: pushToken.data, - }), - }); - if (!updatePushTokenResponse.ok) { - throw new Error( - `Failed to update push token: ${updatePushTokenResponse.status}` - ); - } + if (initialData.user.pushToken !== pushToken.data) { + const updatePushTokenResponse = await updatePushToken( + initialData.user.id, + pushToken.data + ); } else { console.log('Push token is up to date.'); } - allData.user.pushToken = pushToken.data; - await saveInitialData(allData); + initialData.user.pushToken = pushToken.data; + await saveInitialData(initialData); } onSignIn(); - } catch (error) { + } catch (error: unknown) { console.error('Error signing in:', error); if (error.code === 'ERR_REQUEST_CANCELLED') { Alert.alert('Sign in error', 'Sign in was cancelled.'); diff --git a/components/home/Relationship.tsx b/components/home/Relationship.tsx new file mode 100644 index 0000000..9adbafa --- /dev/null +++ b/components/home/Relationship.tsx @@ -0,0 +1,299 @@ +import React, { useEffect, useState } from 'react'; +import { Image, StyleSheet, AppState } from 'react-native'; +import { ThemedText, ThemedView } from '@/components/theme/Theme'; +import { getUser, getRelationship, getPartner, saveRelationshipData } from '@/components/services/SecureStore'; +import RequestRelationship from '@/components/home/RequestRelationship'; +import TextButton from '@/components/theme/buttons/TextButton'; +import { checkRelationshipStatus, updateRelationshipStatus } from '@/constants/APIs'; +import type { User, Relationship, RelationshipData } from '@/constants/Types'; + +const CHECK_TIME = 2; // In minutes + +type RelationshipProps = { + pfpUrl: string | null; +}; + +const Relationship: React.FC = ({ pfpUrl }) => { + const [status, setStatus] = useState(null); + const [user, setUser] = useState(null); + const [showRequestRelationship, setShowRequestRelationship] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchRelationshipStatus(); + setUpPeriodicCheck(); + }, []); + + useEffect(() => { + if (pfpUrl && user) { + setUser(prevUser => + prevUser ? {...prevUser, pfpUrl: pfpUrl} : null); + } + }, [pfpUrl]); + + const handleRequestSent = (relationshipData: RelationshipData) => { + setStatus(relationshipData); + setShowRequestRelationship(false); + }; + + const setUpPeriodicCheck = () => { + let intervalId: NodeJS.Timeout | null = null; + const startChecking = () => { + handleCheckRelationshipStatus(); + intervalId = setInterval(handleCheckRelationshipStatus, 60000*CHECK_TIME); + }; + const stopChecking = () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + }; + const handleAppStateChange = (nextAppState: string) => { + if (nextAppState === 'active') startChecking(); + else stopChecking(); + }; + const subscription = AppState.addEventListener('change', handleAppStateChange); + if (AppState.currentState === 'active') startChecking(); + return () => { + stopChecking(); + subscription.remove(); + }; + }; + + const fetchRelationshipStatus = async () => { + setLoading(true); + try { + const userFromStore: User = await getUser() as User; + if (!userFromStore) throw new Error('User not found in store.'); + setUser(userFromStore); + const relationshipFromStore: Relationship = await getRelationship() as Relationship; + const partnerFromStore: User = await getPartner() as User; + if (!relationshipFromStore || !partnerFromStore) + throw new Error('Relationship not found in store.'); + setStatus({ + relationship: relationshipFromStore, + partner: partnerFromStore, + }); + await handleCheckRelationshipStatus(); + } catch (error) { + console.log('Error fetching relationship status:', error); + } finally { + setLoading(false); + } + }; + + const handleCheckRelationshipStatus = async () => { + try { + const userFromStore: User = await getUser() as User; + if (!userFromStore) throw new Error('User not found in store.'); + const relationshipData: RelationshipData = await checkRelationshipStatus(userFromStore.id); + setStatus(relationshipData); + await saveRelationshipData(relationshipData); + } catch (error) { + console.log('No relationship found or error checking relationship status:', error); + setStatus(null); + } finally { + setLoading(false); + } + }; + + const handleUpdateRelationshipStatus = async (newStatus: 'accepted' | 'rejected') => { + if (!status || !status.relationship || !user || !user.id) { + console.error('No relationship found.'); + return; + } + try { + if (newStatus === 'accepted') { + const updatedRelationshipData: RelationshipData = + await updateRelationshipStatus(user.id, newStatus); + setStatus(updatedRelationshipData); + await saveRelationshipData(updatedRelationshipData); + } else { + await updateRelationshipStatus(user.id, newStatus); + console.log('Rejected relationship. Relationship deleted.'); + setStatus(null); + } + } catch (error) { + console.error('Error updating relationship status:', error); + } + }; + const handleAcceptRequest = () => handleUpdateRelationshipStatus('accepted'); + const handleRejectRequest = () => handleUpdateRelationshipStatus('rejected'); + + if (loading) { + return ( + + Loading... + + ); + } + + const renderRelationshipContent = () => { + if (!status || !status.relationship) { + // Case 1: Not in a relationship + return showRequestRelationship ? ( + + ) : ( + setShowRequestRelationship(true)} + /> + ); + } else if (!status.relationship.isAccepted && user?.id === status.relationship.requestorId) { + // Case 2: Pending relationship & our user requested it + return ( + <> + + Pending Relationship + + + + + + {status.partner.fullName} + + + + + + + + + ); + } else if (!status.relationship.isAccepted && user?.id !== status.relationship.requestorId) { + // Case 3: Pending relationship & someone else requested it + return ( + <> + + Pending Relationship + + + + + + {status.partner.fullName} + + + + + + + + + ); + + } else if (status.relationship.isAccepted) { + // Case 4: In an accepted relationship + return ( + <> + + {status.relationship.title} + + + {user && ( + + + + {user.fullName.split(' ')[0]} + + + )} + {status.partner && ( + + + + {status.partner.fullName.split(' ')[0]} + + + )} + + + ); + } + }; + + return ( + + {renderRelationshipContent()} + + ); +}; +export default Relationship; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + }, + profileContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginTop: 20, + }, + profileWrapper: { + alignItems: 'center', + marginHorizontal: 10, + }, + profilePicture: { + width: 100, + height: 100, + borderRadius: 50, + marginBottom: 10, + }, + name: { + fontSize: 12, + fontWeight: 'bold', + }, + lastChecked: { + fontSize: 12, + marginBottom: 10, + }, + buttonContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + width: '100%', + marginTop: 20, + }, +}); diff --git a/components/home/RequestRelationship.tsx b/components/home/RequestRelationship.tsx new file mode 100644 index 0000000..abe7c19 --- /dev/null +++ b/components/home/RequestRelationship.tsx @@ -0,0 +1,112 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + TextInput, + FlatList, + TouchableOpacity, + StyleSheet, + ActivityIndicator +} from 'react-native'; +import { ThemedText, ThemedView } from '@/components/theme/Theme'; +import { getUser } from '@/components/services/SecureStore'; +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import debounce from 'lodash.debounce'; +import { searchUsers, sendRelationshipRequest } from '@/constants/APIs'; +import type { User, RelationshipData } from '@/constants/Types'; + +const RequestRelationship: + React.FC<{ onRequestSent: (data: RelationshipData) => void }> = ({ onRequestSent }) => { + const scheme = useColorScheme() ?? 'dark'; + const [searchTerm, setSearchTerm] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [currentUserId, setCurrentUserId] = useState(null); + + useEffect(() => { + const fetchCurrentUser = async () => { + const user = await getUser(); + if (user) { + setCurrentUserId(user.id); + } + }; + fetchCurrentUser(); + }, []); + + const handleSearchUsers = useCallback(debounce(async (term: string) => { + if (term.length < 3) { + setSearchResults([]); + return; + } + setIsLoading(true); + const users: User[] = await searchUsers(encodeURIComponent(term)) as User[]; + setSearchResults(users); + }, 300), []); + + const handleSearch = async (text: string) => { + setSearchTerm(text); + handleSearchUsers(text); + }; + + const handleSendRequest = async (targetUserId: number) => { + if (!currentUserId) return; + try { + const relationshipData: RelationshipData = + await sendRelationshipRequest(currentUserId, targetUserId) as RelationshipData; + onRequestSent(relationshipData); + } catch (error) { + console.error('Error sending relationship request:', error); + } + }; + + const renderUserItem = ({ item }: { item: User }) => ( + handleSendRequest(item.id)}> + {item.fullName} + {item.email} + + ); + + return ( + + + {isLoading ? ( + + ) : ( + item.id.toString()} + ListEmptyComponent={No users found} + /> + )} + + ); +}; +export default RequestRelationship; + + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + }, + searchInput: { + height: 40, + borderColor: 'gray', + borderWidth: 1, + marginBottom: 20, + paddingHorizontal: 10, + }, + userItem: { + padding: 10, + borderBottomWidth: 1, + borderBottomColor: 'gray', + }, +}); diff --git a/components/home/TestPush.tsx b/components/home/TestPush.tsx new file mode 100644 index 0000000..fefd705 --- /dev/null +++ b/components/home/TestPush.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useState } from 'react'; +import { StyleSheet, Alert } from 'react-native'; +import { ThemedText } from '@/components/theme/Theme'; +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import FontAwesome from '@expo/vector-icons/FontAwesome'; +import Button from '@/components/theme/buttons/DefaultButton'; +import { getUser } from '@/components/services/SecureStore'; +import { sendPushNotification } from '@/components/services/notifications/PushNotificationManager'; +import type { NotificationMessage } from '@/constants/Types'; + +const TestPush = () => { + const scheme = useColorScheme() ?? 'dark'; + const [pushToken, setPushToken] = useState(null); + + useEffect(() => { + const fetchUserData = async () => { + const user = await getUser(); + if (user) { + setPushToken(user.pushToken); + } + }; + fetchUserData(); + }, []); + + const message: NotificationMessage = { + sound: 'default', + title: 'Test push notification', + body: 'This is a test push notification', + data: { + test: 'test', + }, + }; + + const handleSendPushNotification = async () => { + try { + await sendPushNotification(pushToken, message); + Alert.alert('Success', 'Push notification sent successfully.'); + } catch (error) { + Alert.alert('Error', 'Failed to send push notification.'); + } + }; + + return ( + + ); +}; +export default TestPush; + +const styles = StyleSheet.create({ + buttonLabel: { + fontSize: 16, + }, + buttonIcon: { + paddingRight: 8, + }, +}); diff --git a/components/home/UserInfo.tsx b/components/home/UserInfo.tsx new file mode 100644 index 0000000..c2053fc --- /dev/null +++ b/components/home/UserInfo.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState } from 'react'; +import { StyleSheet, Alert, Image, TouchableOpacity } from 'react-native'; +import { ThemedText, ThemedView } from '@/components/theme/Theme'; +import { getUser, updateUser } from '@/components/services/SecureStore'; +import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; +import * as ImagePicker from 'expo-image-picker'; +import { updateProfilePicture } from '@/constants/APIs'; +import type { User } from '@/constants/Types'; + +type UserInfoProps = { + onPfpUpdate: (url: string) => void; +}; + +const UserInfo: React.FC = ({ onPfpUpdate }) => { + const [user, setUser] = useState(null); + + useEffect(() => { + const fetchUserData = async () => { + try { + const user: User = await getUser() as User; + setUser(user); + if (user.pfpUrl) + onPfpUpdate(user.pfpUrl); + } catch (error) { + console.error('Error fetching user data:', error); + Alert.alert('Error', 'Failed to fetch user data.'); + } + }; + fetchUserData(); + }, [onPfpUpdate]); + + const handleUpdateProfilePicture = async () => { + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (permissionResult.granted === false) { + Alert.alert( + 'Permission Required', + 'You need to grant permission to access your photo library.' + ); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [4, 3], + quality: 1, + }); + if (!result.canceled && result.assets[0].uri) { + try { + const manipulateResult = await manipulateAsync( + result.assets[0].uri, + [{resize: {width: 300, height: 300}}], + {compress: 0.7, format: SaveFormat.JPEG} + ); + const user = await getUser() as User; + const updatedUser = await updateProfilePicture(user.id, manipulateResult.uri); + if (!updatedUser || !updatedUser.pfpUrl) throw new Error('Failed to update user profile picture.'); + setUser(prevData => prevData ? {...prevData, pfpUrl: updatedUser.pfpUrl} as User : updatedUser as User); + await updateUser({pfpUrl: updatedUser.pfpUrl}); + onPfpUpdate(updatedUser.pfpUrl); + } catch (error) { + console.error('Error updating profile picture:', error); + Alert.alert('Error', 'Failed to update profile picture.'); + } + } else { + Alert.alert('Error', 'Failed to select an image.'); + } + }; + return ( + + {user ? ( + + + + + {user.fullName} + {user.email} + + ) : ( + Loading user data... + )} + + ); +}; +export default UserInfo; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + }, + profileContainer: { + alignItems: 'center', + marginTop: 20, + marginBottom: 20, + }, + profilePicture: { + width: 100, + height: 100, + borderRadius: 50, + marginBottom: 10, + }, + name: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 5, + }, + email: { + fontSize: 16, + marginBottom: 20, + }, +}); + + diff --git a/components/services/SecureStore.tsx b/components/services/SecureStore.tsx index 580169e..26c7398 100644 --- a/components/services/SecureStore.tsx +++ b/components/services/SecureStore.tsx @@ -23,6 +23,18 @@ export const getUser = async () => { return null; } }; +export const updateUser = async (updatedFields: Partial) => { + try { + const currentUser: User = await getUser() as User; + if (!currentUser) return null; + const updatedUser: User = { ...currentUser, ...updatedFields }; + await saveUser(updatedUser); + return updatedUser as User; + } catch (error) { + console.error('Error updating user data: ', error); + return null; + } +}; export const savePartner = async (partner: User) => { try { await SecureStore.setItemAsync('partner', JSON.stringify(partner)); @@ -39,6 +51,18 @@ export const getPartner = async () => { return null; } }; +export const updatePartner = async (updatedFields: Partial) => { + try { + const currentPartner: User = await getPartner() as User; + if (!currentPartner) return null; + const updatedPartner: User = { ...currentPartner, ...updatedFields }; + await saveUser(updatedPartner); + return updatedPartner as User; + } catch (error) { + console.error('Error updating user data: ', error); + return null; + } +}; export const saveRelationship = async (relationship: Relationship) => { try { await SecureStore.setItemAsync('relationship', JSON.stringify(relationship)); @@ -55,6 +79,18 @@ export const getRelationship = async () => { return null; } }; +export const updateRelationship = async (updatedFields: Partial) => { + try { + const currentRelationship: Relationship = await getRelationship() as Relationship; + if (!currentRelationship) return null; + const updatedRelationship: Relationship = { ...currentRelationship, ...updatedFields }; + await saveRelationship(updatedRelationship); + return updatedRelationship as Relationship; + } catch (error) { + console.error('Error updating relationship data: ', error); + return null; + } +}; export const saveCountdown = async (countdown: Countdown) => { try { await SecureStore.setItemAsync('countdown', JSON.stringify(countdown)); @@ -71,6 +107,18 @@ export const getCountdown = async () => { return null; } }; +export const updateCountdown = async (updatedFields: Partial) => { + try { + const currentCountdown: Countdown = await getCountdown() as Countdown; + if (!currentCountdown) return null; + const updatedCountdown: Countdown = { ...currentCountdown, ...updatedFields }; + await saveCountdown(updatedCountdown); + return updatedCountdown as Countdown; + } catch (error) { + console.error('Error updating countdown data: ', error); + return null; + } +}; export const saveRelationshipData = async (relationshipData: RelationshipData) => { try { await SecureStore.setItemAsync('partner', JSON.stringify(relationshipData.Partner)); diff --git a/components/services/notifications/PushNotificationManager.tsx b/components/services/notifications/PushNotificationManager.tsx index 82f42de..4504166 100644 --- a/components/services/notifications/PushNotificationManager.tsx +++ b/components/services/notifications/PushNotificationManager.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Platform } from 'react-native'; +import { Alert, Platform } from 'react-native'; import * as Device from 'expo-device'; import * as Notifications from 'expo-notifications'; import Constants from 'expo-constants'; @@ -13,7 +13,11 @@ Notifications.setNotificationHandler({ }), }); -export const sendPushNotification = async(expoPushToken: string, notification: NotificationMessage) => { +export const sendPushNotification = async(expoPushToken: string | null, notification: NotificationMessage) => { + if (!expoPushToken) { + Alert.alert('Error', 'No push token found.'); + return; + } const message = { to: expoPushToken, sound: notification.sound ?? 'default', @@ -21,15 +25,22 @@ export const sendPushNotification = async(expoPushToken: string, notification: N body: notification.body, data: notification.data ?? {}, }; - await fetch('https://exp.host/--/api/v2/push/send', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Accept-encoding': 'gzip, deflate', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(message), + try { + const response = await fetch('https://exp.host/--/api/v2/push/send', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Accept-encoding': 'gzip, deflate', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(message), }); + const result = await response.json(); + console.log('Push notification sent:', result); + } catch (error) { + console.error('Error sending push notification:', error); + Alert.alert('Error', 'Failed to send push notification.'); + } }; const handleRegistrationError = (errorMessage: string) => { diff --git a/constants/APIs.ts b/constants/APIs.ts new file mode 100644 index 0000000..75a6478 --- /dev/null +++ b/constants/APIs.ts @@ -0,0 +1,194 @@ +import * as FileSystem from 'expo-file-system'; +import type { User, RelationshipData } from '@/constants/Types'; + +export const getInitialDataByAppleId = async (appleId: string) => { + try { + const apiUrl = `${process.env.EXPO_PUBLIC_API_URL}/api/users/getInitialDataByAppleId`; + const response = await fetch((apiUrl + `?appleId=${appleId}`), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': process.env.EXPO_PUBLIC_API_KEY ?? '', + }, + }); + return response; + } catch (error: unknown) { + console.error('Error getting user by Apple ID:', error); + throw error; + } +}; + +export const createUser = + async (appleId: string, email: string, fullName: string, pushToken: string) => { + try { + const apiUrl = `${process.env.EXPO_PUBLIC_API_URL}/api/users/createUser`; + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': process.env.EXPO_PUBLIC_API_KEY ?? '', + }, + body: JSON.stringify({ + appleId: appleId, + email: email, + fullName: fullName, + pushToken: pushToken, + }), + }); + if (!response.ok) { + throw new Error( + `Error creating user: ${response.status} ${response.statusText}` + ); + } + const user: User = await response.json() as User; + return user; + } catch (error: unknown) { + console.error('Error creating user:', error); + throw error; + } +}; + +export const updatePushToken = async (userId: number, pushToken: string) => { + try { + const apiUrl = `${process.env.EXPO_PUBLIC_API_URL}/api/users/updatePushToken`; + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': process.env.EXPO_PUBLIC_API_KEY ?? '', + }, + body: JSON.stringify({ + userId: userId, + pushToken: pushToken, + }), + }); + if (!response.ok) { + throw new Error( + `Error updating push token: ${response.status} ${response.statusText}` + ); + } + return response; + } catch (error: unknown) { + console.error('Error updating push token:', error); + throw error; + } +}; + +export const updateProfilePicture = async (userId: number, pfpUrl: string) => { + try { + const apiUrl = `${process.env.EXPO_PUBLIC_API_URL}/api/users/updatePfp`; + const response = await FileSystem.uploadAsync(apiUrl, pfpUrl, { + fieldName: 'file', + httpMethod: 'POST', + uploadType: FileSystem.FileSystemUploadType.MULTIPART, + parameters: { userId: userId.toString() }, + headers: { + 'x-api-key': process.env.EXPO_PUBLIC_API_KEY ?? '', + }, + }); + if (response.status !== 200) + throw new Error( + `Error: Server responded with status ${response.status}: ${response.body}` + ); + const updatedUser: User = JSON.parse(response.body) as User; + return updatedUser; + } catch (error: unknown) { + console.error('Error updating profile picture:', error); + throw error; + } +}; + +export const checkRelationshipStatus = async (userId: number) => { + try { + const apiUrl = `${process.env.EXPO_PUBLIC_API_URL}/api/users/checkRelationship`; + const response = await fetch((apiUrl + `?userId=${userId}`), { + headers: { + 'x-api-key': process.env.EXPO_PUBLIC_API_KEY ?? '', + }, + }); + if (!response.ok) { + throw new Error( + `Error checking relationship status: ${response.status} ${response.statusText}` + ); + } + const relationshipData = await response.json() as RelationshipData; + return relationshipData; + } catch (error: unknown) { + console.error('Error checking relationship status:', error); + throw error; + } +}; + +export const updateRelationshipStatus = async (userId: number, status: 'accepted' | 'rejected') => { + try { + const apiUrl = `${process.env.EXPO_PUBLIC_API_URL}/api/relationships/updateStatus`; + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': process.env.EXPO_PUBLIC_API_KEY ?? '', + }, + body: JSON.stringify({ + userId: userId, + status: status, + }), + }); + if (!response.ok) { + throw new Error( + `Error updating relationship status: ${response.status} ${response.statusText}` + ); + } + const updatedRelationshipData = await response.json() as RelationshipData; + return updatedRelationshipData; + } catch (error: unknown) { + console.error('Error updating relationship status:', error); + throw error; + } +}; + +export const searchUsers = async (searchTerm: string) => { + try { + const apiUrl = `${process.env.EXPO_PUBLIC_API_URL}/api/users/search`; + const response = await fetch((apiUrl + `?searchTerm=${searchTerm}`), { + headers: { + 'x-api-key': process.env.EXPO_PUBLIC_API_KEY ?? '', + }, + }); + if (!response.ok) { + throw new Error( + `Error searching users: ${response.status} ${response.statusText}` + ); + } + const users: User[] = await response.json() as User[]; + return users as User[]; + } catch (error: unknown) { + console.error('Error searching users:', error); + throw error; + } +}; + +export const sendRelationshipRequest = async (userId: number, targetUserId: number) => { + if (!userId || !targetUserId || isNaN(userId) || isNaN(targetUserId)) return; + try { + const apiUrl = `${process.env.EXPO_PUBLIC_API_URL}/api/relationships/createRequest`; + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': process.env.EXPO_PUBLIC_API_KEY ?? '', + }, + body: JSON.stringify({ userId: userId, targetUserId: targetUserId }), + }); + if (!response.ok) { + throw new Error( + `Error sending Relationship request: ${response.status} ${response.statusText}` + ); + } + const relationshipData: RelationshipData = await response.json() as RelationshipData; + return relationshipData; + } catch (error: unknown) { + console.error('Error sending Relationship request:', error); + throw error; + } + +}; diff --git a/constants/Types.ts b/constants/Types.ts index 817e532..6417f33 100644 --- a/constants/Types.ts +++ b/constants/Types.ts @@ -28,7 +28,7 @@ export type UserRelationship = { // & UserRelationship Tables in DB export type RelationshipData = { relationship: Relationship; - Partner: User; + partner: User; }; // Countdown Table in DB export type Countdown = { @@ -42,8 +42,8 @@ export type Countdown = { // API Response export type InitialData = { user: User; - relationshipData: RelationshipData; - countdown: Countdown; + relationshipData?: RelationshipData; + countdown?: Countdown; }; // Message Table in DB export type Message = { diff --git a/package.json b/package.json index 395f324..978010f 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ "dependencies": { "@expo/vector-icons": "^14.0.2", "@react-navigation/native": "^6.0.2", + "@types/lodash.debounce": "^4.0.9", "expo": "~51.0.28", "expo-apple-authentication": "~6.4.2", "expo-clipboard": "~6.0.3", "expo-constants": "~16.0.2", "expo-device": "~6.0.2", + "expo-file-system": "~17.0.1", "expo-font": "~12.0.9", + "expo-image-manipulator": "~12.0.5", "expo-image-picker": "~15.0.7", "expo-linking": "~6.3.1", "expo-location": "~17.0.1", @@ -33,6 +36,7 @@ "expo-status-bar": "~1.12.1", "expo-system-ui": "~3.0.7", "expo-web-browser": "~13.0.3", + "lodash.debounce": "^4.0.8", "react": "18.2.0", "react-dom": "18.2.0", "react-native": "0.74.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5750b46..0f42520 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@react-navigation/native': specifier: ^6.0.2 version: 6.1.18(react-native@0.74.5(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0) + '@types/lodash.debounce': + specifier: ^4.0.9 + version: 4.0.9 expo: specifier: ~51.0.28 version: 51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8)) @@ -29,9 +32,15 @@ importers: expo-device: specifier: ~6.0.2 version: 6.0.2(expo@51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))) + expo-file-system: + specifier: ~17.0.1 + version: 17.0.1(expo@51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))) expo-font: specifier: ~12.0.9 version: 12.0.10(expo@51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))) + expo-image-manipulator: + specifier: ~12.0.5 + version: 12.0.5(expo@51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))) expo-image-picker: specifier: ~15.0.7 version: 15.0.7(expo@51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))) @@ -65,6 +74,9 @@ importers: expo-web-browser: specifier: ~13.0.3 version: 13.0.3(expo@51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))) + lodash.debounce: + specifier: ^4.0.8 + version: 4.0.8 react: specifier: 18.2.0 version: 18.2.0 @@ -1412,6 +1424,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash.debounce@4.0.9': + resolution: {integrity: sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==} + '@types/lodash.isequal@4.5.8': resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} @@ -2345,6 +2360,11 @@ packages: peerDependencies: expo: '*' + expo-image-manipulator@12.0.5: + resolution: {integrity: sha512-zJ8yINjckYw/yfoSuICt4yJ9xr112+W9e5QVXwK3nCAHr7sv45RQ5sxte0qppf594TPl+UoV6Tjim7WpoKipRQ==} + peerDependencies: + expo: '*' + expo-image-picker@15.0.7: resolution: {integrity: sha512-u8qiPZNfDb+ap6PJ8pq2iTO7JKX+ikAUQ0K0c7gXGliKLxoXgDdDmXxz9/6QdICTshJBJlBvI0MwY5NWu7A/uw==} peerDependencies: @@ -7083,6 +7103,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/lodash.debounce@4.0.9': + dependencies: + '@types/lodash': 4.17.10 + '@types/lodash.isequal@4.5.8': dependencies: '@types/lodash': 4.17.10 @@ -8089,6 +8113,11 @@ snapshots: dependencies: expo: 51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8)) + expo-image-manipulator@12.0.5(expo@51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))): + dependencies: + expo: 51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8)) + expo-image-loader: 4.7.0(expo@51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))) + expo-image-picker@15.0.7(expo@51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))): dependencies: expo: 51.0.38(@babel/core@7.25.8)(@babel/preset-env@7.25.8(@babel/core@7.25.8))