diff --git a/.gitignore b/.gitignore index 067a219..9fa449d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ yarn-error.log *.tar.gz *.apk .expo +*.app android/** -ios/** \ No newline at end of file +ios/** diff --git a/app.json b/app.json index 9cc4a86..c9e62ba 100644 --- a/app.json +++ b/app.json @@ -88,7 +88,8 @@ ], "expo-localization", "expo-font", - "expo-router" + "expo-router", + "expo-notifications" ] } } diff --git a/app/_layout.tsx b/app/_layout.tsx index 02b4d8a..121fd00 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -7,6 +7,7 @@ import { DefaultTheme, ThemeProvider, } from "@react-navigation/native"; +import * as Notifications from "expo-notifications"; import { Stack } from "expo-router"; import { Icon, @@ -25,6 +26,7 @@ import { client } from "../graphql/client"; import { useTheme as usePaperTheme } from "../hooks/useTheme"; import { persistor, store } from "../redux/store"; import "../translations/i18n"; +import { registerForPushNotificationsAsync } from "../util/notificationHandler"; function AuthenticatedLayout() { const { t } = useTranslation(); @@ -109,6 +111,33 @@ function AppContent({ currentUser, checkUserSession, bodyshop }: any) { checkUserSession(); }, [checkUserSession]); + useEffect(() => { + registerForPushNotificationsAsync() + .then((token) => console.log("Expo Push Token:", token)) + .catch((error: any) => + console.log("Error getting Expo Push Token:", error) + ); + + const notificationListener = Notifications.addNotificationReceivedListener( + async (notification) => { + console.log("Notification received:", notification); + Notifications.setBadgeCountAsync( + (await Notifications.getBadgeCountAsync()) + 1 + ); + } + ); + + const responseListener = + Notifications.addNotificationResponseReceivedListener((response) => { + console.log("Notification response received:", response); + }); + + return () => { + notificationListener.remove(); + responseListener.remove(); + }; + }, []); + if (currentUser.authorized === null) { return ( diff --git a/app/jobs/[jobId]/_layout.tsx b/app/jobs/[jobId]/_layout.tsx index 0e00fdb..af391b0 100644 --- a/app/jobs/[jobId]/_layout.tsx +++ b/app/jobs/[jobId]/_layout.tsx @@ -3,7 +3,7 @@ import { Tabs } from "expo-router"; import { useTranslation } from "react-i18next"; import { useTheme } from "react-native-paper"; -function JobTabLayout(props) { +function JobTabLayout() { const { t } = useTranslation(); const theme = useTheme(); diff --git a/app/jobs/_layout.tsx b/app/jobs/_layout.tsx index 3c3123b..7d3bee4 100644 --- a/app/jobs/_layout.tsx +++ b/app/jobs/_layout.tsx @@ -1,8 +1,26 @@ -import { Stack } from "expo-router"; +import { openImagePicker } from "@/redux/photos/photos.actions"; +import * as Haptics from "expo-haptics"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { IconButton } from "react-native-paper"; +import { connect } from "react-redux"; -function JobsStack() { +const mapDispatchToProps = (dispatch) => ({ + openImagePicker: (id) => dispatch(openImagePicker(id)), +}); +export default connect(null, mapDispatchToProps)(JobsStack); + +function JobsStack({ openImagePicker }) { const { t } = useTranslation(); + const { jobId } = useLocalSearchParams(); + console.log("*** ~ JobsStack ~ jobId:", jobId); + + const handleUpload = useCallback(() => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + openImagePicker(jobId); + }, [openImagePicker, jobId]); + return ( ({ //headerShown: false, title: (route.params as any)?.title || "Job Details", + headerRight: () => ( + + ), })} /> ); } - -export default JobsStack; diff --git a/components/jobs-list/job-list-item.jsx b/components/jobs-list/job-list-item.jsx index 0859a4a..b981c2f 100644 --- a/components/jobs-list/job-list-item.jsx +++ b/components/jobs-list/job-list-item.jsx @@ -7,12 +7,10 @@ import { Chip, IconButton, Text, useTheme } from "react-native-paper"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { logImEXEvent } from "../../firebase/firebase.analytics"; -import { setCameraJobId } from "../../redux/app/app.actions"; import { openImagePicker } from "../../redux/photos/photos.actions"; const mapStateToProps = createStructuredSelector({}); const mapDispatchToProps = (dispatch) => ({ - setCameraJobId: (id) => dispatch(setCameraJobId(id)), openImagePicker: (id) => dispatch(openImagePicker(id)), }); diff --git a/components/jobs-list/jobs-list.jsx b/components/jobs-list/jobs-list.jsx index 2f52fa7..f8848a9 100644 --- a/components/jobs-list/jobs-list.jsx +++ b/components/jobs-list/jobs-list.jsx @@ -48,7 +48,7 @@ export function JobListComponent({ bodyshop }) { const jobs = data ? [...(data?.jobs || []), { id: "footer-spacer" }] : []; return ( - + - + + + + diff --git a/graphql/bodyshop.queries.js b/graphql/bodyshop.queries.js index cca90d8..114d767 100644 --- a/graphql/bodyshop.queries.js +++ b/graphql/bodyshop.queries.js @@ -23,3 +23,11 @@ export const QUERY_SHOP_ID = gql` } } `; + + +export const UPDATE_FCM_TOKEN = gql` +mutation UPDATE_FCM_TOKEN($email: String!, $token: jsonb!) { + update_users(where: {email: {_eq: $email}}, _append: {fcmtokens: $token}) { + affected_rows + } +}`; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c5597a8..2ade863 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "expo-application": "~7.0.7", "expo-constants": "~18.0.10", "expo-dev-client": "~6.0.16", + "expo-device": "^8.0.9", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", @@ -8436,6 +8437,18 @@ "expo": "*" } }, + "node_modules/expo-device": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.9.tgz", + "integrity": "sha512-XqRpaljDNAYZGZzMpC+b9KZfzfydtkwx3pJAp6ODDH+O/5wjAw+mLc5wQMGJCx8/aqVmMsAokec7iebxDPFZDA==", + "license": "MIT", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-eas-client": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.7.tgz", @@ -15282,6 +15295,32 @@ "typescript-compare": "^0.0.2" } }, + "node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index 3a532d7..c2ad513 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "expo-application": "~7.0.7", "expo-constants": "~18.0.10", "expo-dev-client": "~6.0.16", + "expo-device": "^8.0.9", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", diff --git a/util/notificationHandler.js b/util/notificationHandler.js new file mode 100644 index 0000000..14cef2b --- /dev/null +++ b/util/notificationHandler.js @@ -0,0 +1,72 @@ +import { getCurrentUser } from '@/firebase/firebase.utils'; +import { UPDATE_FCM_TOKEN } from '@/graphql/bodyshop.queries'; +import { client } from '@/graphql/client'; +import Constants from 'expo-constants'; +import * as Device from 'expo-device'; +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldPlaySound: false, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + + +async function registerForPushNotificationsAsync() { + console.log("Registering for push notifications..."); + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('default', { + name: 'default', + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#FF231F7C', + }); + } + + if (Device.isDevice) { + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + if (existingStatus !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + if (finalStatus !== 'granted') { + throw new Error('Permission not granted to get push token for push notification!'); + } + const projectId = + Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId; + if (!projectId) { + throw new Error('Project ID not found'); + } + try { + const pushTokenString = ( + await Notifications.getExpoPushTokenAsync({ + projectId, + }) + ).data; + console.log(pushTokenString); + + //Write the FCM token to database. + + const result = await client.mutate({ + mutation: UPDATE_FCM_TOKEN, + variables: { + email: (await getCurrentUser()).email, + token: { [pushTokenString]: { pushTokenString, platform: Platform.OS, timestamp: Date.now() } }, + }, + }); + console.log("FCM token updated in database:", result); + return pushTokenString; + } catch (error) { + throw error + } + } else { + throw new Error('Must use physical device for push notifications'); + } +} + +export { registerForPushNotificationsAsync };