diff --git a/.env.public b/.env.public index 7ac4e57..e3fea07 100644 --- a/.env.public +++ b/.env.public @@ -4,4 +4,5 @@ EXPO_PUBLIC_LOG_DEBUG=false EXPO_PUBLIC_LOG_LEVEL=debug EXPO_PUBLIC_SENTRY_DSN=https://6bf30b84d315b016b9529662b5f11182@o4505033974284288.ingest.us.sentry.io/4508070604374016 EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=762459437378-v3tsf9qvlfmvftirgt3sllj5p1rpi1lp.apps.googleusercontent.com -EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=333850807930-ij59g3k6p4pslnus2elrm5egkgju61e3.apps.googleusercontent.com \ No newline at end of file +EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=333850807930-ij59g3k6p4pslnus2elrm5egkgju61e3.apps.googleusercontent.com +EXPO_PUBLIC_EAS_PROJECT_ID=6afd57d1-5bac-48fc-b3f3-40a510289ae2 \ No newline at end of file diff --git a/app.config.ts b/app.config.ts index 7854b4c..3812d84 100644 --- a/app.config.ts +++ b/app.config.ts @@ -3,6 +3,7 @@ import { ConfigContext, ExpoConfig } from 'expo/config' export default ({ config: initConfig }: ConfigContext): ExpoConfig => { const appName = initConfig?.name || 'Skeleton' const environment = process.env.EXPO_PUBLIC_NODE_ENV as 'production' | 'development' | 'testing' + const projectId = process.env.EXPO_PUBLIC_EAS_PROJECT_ID const name = { production: appName, @@ -26,6 +27,9 @@ export default ({ config: initConfig }: ConfigContext): ExpoConfig => { googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY_IOS, usesNonExemptEncryption: false, }, + infoPlist: { + UIBackgroundModes: ['remote-notification', 'processing'], + }, }, android: { ...initConfig.android, @@ -36,14 +40,14 @@ export default ({ config: initConfig }: ConfigContext): ExpoConfig => { }, }, updates: { - url: 'https://u.expo.dev/6afd57d1-5bac-48fc-b3f3-40a510289ae2', + url: `https://u.expo.dev/${projectId}`, }, runtimeVersion: { policy: 'appVersion', }, extra: { eas: { - projectId: '6afd57d1-5bac-48fc-b3f3-40a510289ae2', + projectId, }, }, } diff --git a/app.json b/app.json index e538df9..752b220 100644 --- a/app.json +++ b/app.json @@ -42,6 +42,14 @@ } } ], + [ + "expo-notifications", + { + "icon": "@assets/png/logo.png", + "color": "#ffffff", + "defaultChannel": "default" + } + ], [ "expo-dev-launcher", { diff --git a/package.json b/package.json index e11ec06..05e3c04 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "@react-native-masked-view/masked-view": "^0.3.1", "@sentry/react-native": "~5.24.3", "@shopify/flash-list": "1.6.4", + "@tanstack/react-query": "^5.59.15", + "axios": "^1.7.7", "expo": "~51.0.38", "expo-apple-authentication": "~6.4.2", "expo-application": "~5.9.1", @@ -66,9 +68,11 @@ "expo-linking": "~6.3.1", "expo-localization": "~15.0.3", "expo-location": "~17.0.1", + "expo-notifications": "~0.28.18", "expo-router": "~3.5.23", "expo-status-bar": "~1.12.1", "expo-system-ui": "~3.0.7", + "expo-task-manager": "~11.8.2", "expo-updates": "~0.25.27", "i18next": "^23.15.1", "immer": "^10.1.1", diff --git a/src/api/jokes.ts b/src/api/jokes.ts new file mode 100644 index 0000000..b532e83 --- /dev/null +++ b/src/api/jokes.ts @@ -0,0 +1,18 @@ +import axios from 'axios' + +const BASE_URL = 'https://icanhazdadjoke.com/' + +interface JokeResponse { + id: string + joke: string + status: number +} + +export const fetchRandomJoke = async (): Promise => { + const res = await axios.get(BASE_URL, { + headers: { + Accept: 'application/json', + }, + }) + return res.data +} diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index bcc1d4f..2cc490d 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Colors } from 'react-native-ui-lib' import { ErrorFallback } from '@app/components' import { ios } from '@app/env' +import { useNotifications } from '@app/hooks' import { iconSizes } from '@app/theme/designSystem' const TabLayout = () => { @@ -20,6 +21,8 @@ const TabLayout = () => { ) + useNotifications() + return ( { const ref = useNavigationContainerRef() const { fontsFinishedLoading } = useLoadFonts() const router = useRouter() + const queryClient = new QueryClient() useEffect(() => { if (fontsFinishedLoading) { @@ -60,11 +62,13 @@ const RootLayout = () => { useStorageDevTools() return ( - - - - - + + + + + + + ) } diff --git a/src/env/index.ts b/src/env/index.ts index 9d7afdc..0ab9435 100644 --- a/src/env/index.ts +++ b/src/env/index.ts @@ -23,3 +23,5 @@ export const TRANSLATIONS_DEBUG = !IS_PROD && false export const SENTRY_DSN = process.env.EXPO_PUBLIC_SENTRY_DSN export const GOOGLE_WEB_CLIENT_ID = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID + +export const EAS_PROJECT_ID = process.env.EXPO_PUBLIC_EAS_PROJECT_ID diff --git a/src/hooks/index.ts b/src/hooks/index.ts index aebf7dc..85cfbf4 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,3 +2,5 @@ export { default as useLoadFonts } from './useLoadFonts' export { default as useConnectionStatus } from './useConnectionStatus' export { default as useAuth } from './useAuth' export { default as useAvoidKeyboard } from './useAvoidKeyboard' +export { default as useJokes } from './useJokes' +export { default as useNotifications } from './useNotifications' diff --git a/src/hooks/useConnectionStatus.ts b/src/hooks/useConnectionStatus.ts index 1fccaf2..cc9c0c1 100644 --- a/src/hooks/useConnectionStatus.ts +++ b/src/hooks/useConnectionStatus.ts @@ -1,9 +1,12 @@ import * as NetInfo from '@react-native-community/netinfo' -import { useEffect, useState } from 'react' +import { onlineManager } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useStore } from '@app/store' import { logger } from '@app/utils' const useConnectionStatus = () => { - const [hasInternetConnection, setHasInternetConnection] = useState(true) + const hasInternetConnection = useStore(state => state.connection.hasInternetConnection) + const setConnectionStatus = useStore(state => state.setConnectionStatus) const onChange: NetInfo.NetInfoChangeHandler = ({ isInternetReachable }) => { if (isInternetReachable != null) { @@ -15,7 +18,9 @@ const useConnectionStatus = () => { } return isInternetReachable } - setHasInternetConnection(updateStateAction) + setConnectionStatus(updateStateAction(hasInternetConnection)) + + onlineManager.setOnline(isInternetReachable) } } diff --git a/src/hooks/useJokes.ts b/src/hooks/useJokes.ts new file mode 100644 index 0000000..985d065 --- /dev/null +++ b/src/hooks/useJokes.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchRandomJoke } from '@app/api/jokes' + +const useJokes = () => { + return useQuery({ + queryKey: ['joke'], + queryFn: fetchRandomJoke, + select: data => data.joke, + refetchOnReconnect: 'always', + experimental_prefetchInRender: true, + }) +} + +export default useJokes diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 0000000..05a9b6a --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,79 @@ +import * as Notifications from 'expo-notifications' +import { useEffect, useRef, useState } from 'react' +import { Alert } from 'react-native' +import { EAS_PROJECT_ID } from '@app/env' + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), +}) + +const registerForPushNotificationsAsync = async (): Promise => { + try { + const { status: existingStatus } = await Notifications.getPermissionsAsync() + let finalStatus = existingStatus + + if (existingStatus !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync() + finalStatus = status + } + + if (finalStatus !== 'granted') { + Alert.alert('Failed to get push token for push notification!') + return null + } + + const projectId = EAS_PROJECT_ID + if (!projectId) { + throw new Error('Project ID not found') + } + + const token = (await Notifications.getExpoPushTokenAsync({ projectId })).data + return token + } catch (e) { + console.error('Error getting push token:', e) + return null + } +} + +const useNotifications = () => { + const [expoPushToken, setExpoPushToken] = useState(null) + const [notification, setNotification] = useState(null) + const notificationListener = useRef(null) + const responseListener = useRef(null) + + const registerPushToken = async () => { + const token = await registerForPushNotificationsAsync() + if (token) { + setExpoPushToken(token) + } + } + + useEffect(() => { + registerPushToken() + + notificationListener.current = Notifications.addNotificationReceivedListener(n => setNotification(n)) + responseListener.current = Notifications.addNotificationResponseReceivedListener(response => { + console.log('User interacted with notification:', response) + }) + + return () => { + if (notificationListener.current) { + Notifications.removeNotificationSubscription(notificationListener.current) + } + if (responseListener.current) { + Notifications.removeNotificationSubscription(responseListener.current) + } + } + }, []) + + return { + notification, + token: expoPushToken, + } +} + +export default useNotifications diff --git a/src/screens/Demo/index.tsx b/src/screens/Demo/index.tsx index 6f7eb18..517b8dd 100644 --- a/src/screens/Demo/index.tsx +++ b/src/screens/Demo/index.tsx @@ -1,20 +1,34 @@ import { withProfiler } from '@sentry/react-native' +import { scheduleNotificationAsync } from 'expo-notifications' import { Link } from 'expo-router' import { useTranslation } from 'react-i18next' import { Button, Colors, Text, View } from 'react-native-ui-lib' -import { useAuth } from '@app/hooks' +import { useAuth, useJokes } from '@app/hooks' const Demo = () => { const { t, i18n } = useTranslation('translation', { keyPrefix: 'modules.demoScreen' }) const { signOut } = useAuth() + const jokeQuery = useJokes() + + const scheduleNotification = async () => { + await scheduleNotificationAsync({ + content: { + title: t('jokeOfTheDay'), + body: jokeQuery.data, + }, + trigger: null, + }) + jokeQuery.refetch() + } return ( - + {t('navigateToMap')} +