diff --git a/.env.template b/.env.template new file mode 100644 index 00000000..b592d4e8 --- /dev/null +++ b/.env.template @@ -0,0 +1,9 @@ +BASE_UTL= +RDS_SESSION= +FIREBASE_PROJECT_ID= +FIREBASE_MESSAGING_SENDER_ID= +FIREBASE_STORAGE_BUCKET= +MOBILE_SDK_APP_ID= +OAUTH_CLIENT_ID= +FIREBASE_CURRENT_API_KEY= +OTHER_PLATFORM_OAUTH_CLIENT_ID= diff --git a/.gitignore b/.gitignore index d8350ee8..658a4925 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,9 @@ buck-out/ # CocoaPods /ios/Pods/ +# ignore google-services +/android/app/google-services.json + #env diff --git a/App.tsx b/App.tsx index ff648bd0..5b4ab14e 100644 --- a/App.tsx +++ b/App.tsx @@ -7,7 +7,6 @@ import reducers from './src/reducers'; import { Provider } from 'react-redux'; import createSagaMiddleware from '@redux-saga/core'; import rootSaga from './src/sagas/rootSaga'; - const sagaMiddleware = createSagaMiddleware(); const middleware = [sagaMiddleware]; export const store = compose(applyMiddleware(...middleware))(createStore)( diff --git a/__tests__/Goals/components/NotificationForm.test.tsx b/__tests__/Goals/components/NotificationForm.test.tsx new file mode 100644 index 00000000..d1d59d11 --- /dev/null +++ b/__tests__/Goals/components/NotificationForm.test.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import NotifyForm from '../../../src/components/Notify/NotifyForm'; +import { AuthContext } from '../../../src/context/AuthContext'; +import { + postFcmToken, + getAllUsers, + sendNotification, +} from '../../../src/screens/AuthScreen/Util'; + +// Mock the functions used in the component +jest.mock('../../../src/screens/AuthScreen/Util', () => ({ + postFcmToken: jest.fn(), + sendNotification: jest.fn(), + getAllUsers: jest.fn(() => Promise.resolve([])), // Mock getAllUsers with an empty array +})); + +jest.mock('@react-native-firebase/messaging', () => ({ + firebase: { + messaging: jest.fn(() => ({ + getToken: jest.fn(() => Promise.resolve('mocked-fcm-token')), + hasPermission: jest.fn(() => Promise.resolve(1)), // Mock permission granted + requestPermission: jest.fn(() => Promise.resolve()), // Mock permission request + })), + }, +})); + +describe('NotifyForm', () => { + const loggedInUserData = { token: 'user-token' }; + + const renderComponent = () => { + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the form with title, description, and Notify button', () => { + const { getByText, getByPlaceholderText } = renderComponent(); + + expect(getByText('Title:')).toBeTruthy(); + expect(getByText('Description:')).toBeTruthy(); + expect(getByText('Notify To:')).toBeTruthy(); + expect(getByPlaceholderText('Enter title')).toBeTruthy(); + expect(getByPlaceholderText('Enter description')).toBeTruthy(); + expect(getByText('Notify')).toBeTruthy(); + }); + + it('Calls postFcmToken', async () => { + renderComponent(); + + await waitFor(() => { + expect(postFcmToken).toHaveBeenCalledWith( + 'mocked-fcm-token', + 'user-token', + ); + }); + }); + + it('fetches users and updates the dropdown', async () => { + const mockUsers = [ + { id: '1', username: 'john_doe', first_name: 'John', last_name: 'Doe' }, + { id: '2', username: 'jane_doe', first_name: 'Jane', last_name: 'Doe' }, + ]; + + getAllUsers.mockResolvedValue(mockUsers); // Mock resolved users + + const { getByTestId, getByText } = renderComponent(); + + // Wait for users to load + await waitFor(() => { + expect(getAllUsers).toHaveBeenCalledWith('user-token'); + }); + + const dropdown = getByTestId('dropdown'); + fireEvent.press(dropdown); // Simulate dropdown press to show user list + + await waitFor(() => { + expect(getByText('john_doe')).toBeTruthy(); + expect(getByText('jane_doe')).toBeTruthy(); + }); + }); + + it('selects a user from the dropdown and sends a notification', async () => { + const mockUsers = [ + { id: '1', username: 'john_doe', first_name: 'John', last_name: 'Doe' }, + ]; + + getAllUsers.mockResolvedValue(mockUsers); + + const { getByTestId, getByPlaceholderText, getByText } = renderComponent(); + + // Wait for users to load + await waitFor(() => { + expect(getAllUsers).toHaveBeenCalledWith('user-token'); + }); + + const dropdown = getByTestId('dropdown'); + fireEvent.press(dropdown); // Open dropdown + + // Select a user from the dropdown + await waitFor(() => { + fireEvent.press(getByText('john_doe')); + }); + + // Fill in title and description + fireEvent.changeText(getByPlaceholderText('Enter title'), 'Test Title'); + fireEvent.changeText( + getByPlaceholderText('Enter description'), + 'Test Description', + ); + + // Press Notify button + fireEvent.press(getByText('Notify')); + + await waitFor(() => { + expect(sendNotification).toHaveBeenCalledWith( + 'Test Title', + 'Test Description', + '1', + 'user-token', + ); + }); + }); +}); diff --git a/__tests__/screens/NotifyScreen.test.tsx b/__tests__/screens/NotifyScreen.test.tsx new file mode 100644 index 00000000..43af49e6 --- /dev/null +++ b/__tests__/screens/NotifyScreen.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import NotifyScreen from '../../src/screens/NotifyScreen/NotifyScreen'; + +jest.mock('../../src/screens/AuthScreen/Util', () => ({ + postFcmToken: jest.fn(), + sendNotification: jest.fn(), + getAllUsers: jest.fn(() => Promise.resolve([])), // Mock getAllUsers with an empty array +})); + +jest.mock('@react-native-firebase/messaging', () => ({ + firebase: { + messaging: jest.fn(() => ({ + getToken: jest.fn(() => Promise.resolve('mocked-fcm-token')), + hasPermission: jest.fn(() => Promise.resolve(1)), // Mock permission granted + requestPermission: jest.fn(() => Promise.resolve()), // Mock permission request + })), + }, +})); +describe('NotifyScreen', () => { + it('should render correctly with title and NotifyForm', async () => { + const { getByText } = render(); + + expect(getByText('Event Notifications')).toBeTruthy(); + // Wait for the getToken to be called + }); +}); diff --git a/android/app/build.gradle b/android/app/build.gradle index 89dae967..35b12872 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,7 +1,7 @@ apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" - +apply plugin: 'com.google.gms.google-services' /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. @@ -67,6 +67,8 @@ android { dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") + implementation(platform("com.google.firebase:firebase-bom:33.1.2")) + implementation("com.google.firebase:firebase-analytics") if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") @@ -76,3 +78,4 @@ dependencies { } apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project) +apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bc7b2def..3cf3cdbd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + + + + + + + + + + + + + + + + + + + + + #FFF + diff --git a/android/build.gradle b/android/build.gradle index cda1bfd9..561c04cc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -16,6 +16,7 @@ buildscript { classpath("com.android.tools.build:gradle") classpath("com.facebook.react:react-native-gradle-plugin") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") + classpath("com.google.gms:google-services:4.3.15") } } diff --git a/bin/postInstall b/bin/postInstall new file mode 100644 index 00000000..61078b1c --- /dev/null +++ b/bin/postInstall @@ -0,0 +1,12 @@ +#!usr/bin/env node +run('node ./src/service/googleServiceTemplate.js'); + +function run(command) { + console.info(`./bin/postInstall scripit running: ${command}`); + try { + require('child_process').execSync(command, { stdio: 'inherit' }); + } catch (err) { + console.error(`./bin/postInstall failed on command ${command}`); + process.exit(err.status); + } +} diff --git a/index.js b/index.js index 9b739329..4a2e2cfd 100644 --- a/index.js +++ b/index.js @@ -5,5 +5,8 @@ import { AppRegistry } from 'react-native'; import App from './App'; import { name as appName } from './app.json'; +import firebase from '@react-native-firebase/app'; + +firebase.initializeApp(); AppRegistry.registerComponent(appName, () => App); diff --git a/jest-setup.js b/jest-setup.js index 120782ec..4841ae8c 100644 --- a/jest-setup.js +++ b/jest-setup.js @@ -1,6 +1,14 @@ import { jest } from '@jest/globals'; -import mockRNDeviceInfo from 'react-native-device-info/jest/react-native-device-info-mock'; - -require('react-native-reanimated/lib/reanimated2/jestUtils').setUpTests(); -jest.mock('react-native-device-info', () => mockRNDeviceInfo); jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter'); +jest.mock('@react-native-firebase/app', () => { + return { + firebase: { + app: jest.fn(() => ({ + initializeApp: jest.fn(), + })), + messaging: jest.fn(() => ({ + getToken: jest.fn(), + })), + }, + }; +}); diff --git a/package.json b/package.json index 1100385e..57d4db8c 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,18 @@ "build-assets-folder": "cd android/app/src/main && if [ -d 'assets' ]; then rm -r assets; fi", "build": "mkdir -p android/app/src/main/assets && npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/res && cd android && ./gradlew assembleDebug", "build-release": "mkdir -p android/app/src/main/assets && npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/build/intermediates/res/merged/release/ && rm -rf android/app/src/main/res/drawable-* && rm -rf android/app/src/main/res/raw/* && cd android && ./gradlew bundleRelease" + "postinstall": "node ./bin/postInstall" }, "dependencies": { "@babel/helper-hoist-variables": "^7.24.7", "@react-native-async-storage/async-storage": "^1.15.16", "@react-native-community/netinfo": "^5.9.9", + "@react-native-community/push-notification-ios": "^1.11.0", "@react-native-community/slider": "^4.4.3", + "@react-native-firebase/app": "^20.4.0", + "@react-native-firebase/messaging": "^20.4.0", "@react-native-masked-view/masked-view": "^0.2.6", + "@react-native-picker/picker": "^2.7.7", "@react-navigation/bottom-tabs": "^6.0.9", "@react-navigation/drawer": "^6.1.8", "@react-navigation/material-top-tabs": "^6.6.4", @@ -28,6 +33,7 @@ "@react-navigation/native-stack": "^6.9.12", "@react-navigation/stack": "^6.2.0", "axios": "^0.26.0", + "dotenv": "^16.4.5", "eslint-plugin-prettier": "^4.1.0", "moment": "^2.29.4", "react": "18.2.0", @@ -39,6 +45,7 @@ "react-native-circular-progress-indicator": "^4.4.2", "react-native-collapsible": "^1.6.1", "react-native-collapsible-tab-view": "^6.2.1", + "react-native-config": "^1.5.3", "react-native-date-picker": "^4.2.13", "react-native-datepicker": "^1.7.2", "react-native-device-info": "^10.8.0", @@ -53,6 +60,7 @@ "react-native-pager-view": "^6.2.1", "react-native-paper": "^5.11.2", "react-native-progress": "^5.0.1", + "react-native-push-notification": "^8.1.1", "react-native-radio-buttons-group": "^2.2.11", "react-native-reanimated": "^3.9.0-rc.1", "react-native-safe-area-context": "^3.2.0", @@ -84,6 +92,7 @@ "@types/react": "^18.2.6", "@types/react-native": "^0.66.4", "@types/react-native-datepicker": "^1.7.1", + "@types/react-native-push-notification": "^8.1.4", "@types/react-test-renderer": "^18.0.0", "@typescript-eslint/eslint-plugin": "^5.7.0", "@typescript-eslint/parser": "^5.7.0", diff --git a/src/actions/LocalNotification.ts b/src/actions/LocalNotification.ts new file mode 100644 index 00000000..7e574c4c --- /dev/null +++ b/src/actions/LocalNotification.ts @@ -0,0 +1,22 @@ +import PushNotification from 'react-native-push-notification'; + +const LocalNotification = () => { + const key = Date.now().toString(); // Key must be unique everytime + PushNotification.createChannel( + { + channelId: key, // (required) + channelName: 'Local messasge', // (required) + channelDescription: 'Notification for Local message', // (optional) default: undefined. + importance: 4, // (optional) default: 4. Int value of the Android notification importance + vibrate: true, // (optional) default: true. Creates the default vibration patten if true. + }, + (created) => console.log(`createChannel returned '${created}'`), // (optional) callback returns whether the channel was created, false means it already existed. + ); + PushNotification.localNotification({ + channelId: key, //this must be same with channelid in createchannel + title: 'Local Message', + message: 'Local message !!', + }); +}; + +export default LocalNotification; diff --git a/src/actions/RemoteNotification.tsx b/src/actions/RemoteNotification.tsx new file mode 100644 index 00000000..ee5d40eb --- /dev/null +++ b/src/actions/RemoteNotification.tsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import { PermissionsAndroid, Platform } from 'react-native'; +import PushNotification from 'react-native-push-notification'; + +const checkApplicationPermission = async () => { + if (Platform.OS === 'android') { + try { + await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, + ); + } catch (error) { + console.error(error); + } + } +}; + +const RemoteNotification = () => { + useEffect(() => { + checkApplicationPermission(); + // Using this function as we are rendering local notification so without this function we will receive multiple notification for same notification + // We have same channelID for every FCM test server notification. + PushNotification.getChannels(function (channel_ids) { + channel_ids.forEach((id) => { + PushNotification.deleteChannel(id); + }); + }); + PushNotification.configure({ + // (optional) Called when Token is generated (iOS and Android) + onRegister: function (token) { + console.log('TOKEN:', token); + }, + + // (required) Called when a remote or local notification is opened or received + onNotification: function (notification) { + const { message, title, id } = notification; + let strTitle: string = JSON.stringify(title).split('"').join(''); + let strBody: string = JSON.stringify(message).split('"').join(''); + const key: string = JSON.stringify(id).split('"').join(''); + PushNotification.createChannel( + { + channelId: key, // (required & must be unique) + channelName: 'remote messasge', // (required) + channelDescription: 'Notification for remote message', // (optional) default: undefined. + importance: 4, // (optional) default: 4. Int value of the Android notification importance + vibrate: true, // (optional) default: true. Creates the default vibration patten if true. + }, + (created) => console.log(`createChannel returned '${created}'`), // (optional) callback returns whether the channel was created, false means it already existed. + ); + PushNotification.localNotification({ + channelId: key, //this must be same with channelId in createchannel + title: strTitle, + message: strBody, + }); + console.log( + 'REMOTE NOTIFICATION ==>', + title, + message, + id, + notification, + ); + // process the notification here + }, + // Android only: GCM or FCM Sender ID + senderID: '1234567890', + popInitialNotification: true, + requestPermissions: true, + }); + }, []); + return null; +}; +export default RemoteNotification; diff --git a/src/components/Notify/NotifyForm.tsx b/src/components/Notify/NotifyForm.tsx index 66eb6c4b..8a44b260 100644 --- a/src/components/Notify/NotifyForm.tsx +++ b/src/components/Notify/NotifyForm.tsx @@ -1,22 +1,76 @@ +import React, { useContext, useEffect, useState } from 'react'; import { View, Text, TextInput, - Picker, - Button, StyleSheet, + TouchableOpacity, + FlatList, + Image, + Keyboard, } from 'react-native'; -import React, { useState } from 'react'; +import Colors from '../../constants/colors/Colors'; +import StyleConfig from '../../utils/StyleConfig'; +import { scale } from '../../utils/utils'; +import { + getAllUsers, + postFcmToken, + sendNotification, +} from '../../screens/AuthScreen/Util'; +import { AuthContext } from '../../context/AuthContext'; +import { firebase } from '@react-native-firebase/messaging'; const NotifyForm = () => { const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); - const [notifyTo, setNotifyTo] = useState(''); + const [isDropDownSelected, setIsDropDownSelected] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [allUsers, setAllUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const { loggedInUserData } = useContext(AuthContext); + const token = loggedInUserData?.token; + + const selectDropDown = () => { + Keyboard.dismiss(); + setIsDropDownSelected(!isDropDownSelected); + }; - const handleButtonPress = () => { + const handleDropDownPress = (item: any) => { + setSelectedUser(item); + setIsDropDownSelected(false); + }; + + const getFCMToken = async () => { + const permission = await firebase.messaging().hasPermission(); + if (permission) { + const fcmToken_ = await firebase.messaging().getToken(); + + await postFcmToken(fcmToken_, token); + } else { + await firebase.messaging().requestPermission(); + } + }; + const handleButtonPress = async () => { // Handle the button press and perform necessary actions (e.g., send notification) - console.log('Notification sent:', { title, description, notifyTo }); + console.log('setSelected User', { + title, + description, + notifyTo: selectedUser?.id, + }); + await sendNotification(title, description, selectedUser?.id, token); }; + + useEffect(() => { + const fetchData = async () => { + const allUser = await getAllUsers(loggedInUserData?.token); + setAllUsers(allUser); + setIsLoading(false); + }; + fetchData(); + getFCMToken(); + }, []); + return ( Title: @@ -25,6 +79,7 @@ const NotifyForm = () => { value={title} onChangeText={(text) => setTitle(text)} placeholder="Enter title" + placeholderTextColor={'black'} /> Description: @@ -33,41 +88,202 @@ const NotifyForm = () => { value={description} onChangeText={(text) => setDescription(text)} placeholder="Enter description" + placeholderTextColor={'black'} multiline /> Notify To: - setNotifyTo(itemValue)} - > - - - {/* Add more items as needed */} - - -