From a8849f0a732513bd7488adc31af5cb7677b883ee Mon Sep 17 00:00:00 2001 From: Mantri Ramkishor Date: Mon, 12 May 2025 19:54:06 +0530 Subject: [PATCH] NTP-55167 | Map View (#1140) Co-authored-by: Pulkit Barwal Co-authored-by: Aishwarya Srivastava --- .github/CODEOWNERS | 36 ++-- .github/workflows/hardReleaseParent.yml | 2 + .github/workflows/hardReleaseTele.yml | 4 +- .github/workflows/newBuild.yml | 26 ++- RN-UI-LIB | 2 +- android/app/build.gradle | 4 +- android/app/src/main/AndroidManifest.xml | 4 + buildFlavor/field/buildNumber.txt | 2 +- buildFlavor/field/buildVersion.txt | 2 +- package.json | 7 +- src/action/authActions.ts | 19 ++ src/action/filterActions.ts | 3 +- src/assets/icons/BaseLocationIcon.tsx | 27 +++ src/assets/icons/CurrentLocation.tsx | 23 +++ src/assets/icons/DefaultMap.tsx | 13 ++ src/assets/icons/FreeMap.tsx | 13 ++ src/assets/icons/ListIcon.tsx | 19 ++ src/assets/icons/LocationMap.tsx | 12 ++ src/assets/icons/MapButton.tsx | 47 +++++ src/assets/icons/MapDirections.tsx | 19 ++ src/assets/icons/MarkerTip.tsx | 25 +++ src/assets/icons/NewLocationIcon.tsx | 4 +- src/assets/icons/OpenMapIcon.tsx | 22 ++ src/assets/icons/SearchIcon.tsx | 19 ++ .../AgentActivityConfigurableConstants.ts | 36 ++-- src/common/Constants.ts | 73 +++++++ src/common/TrackingComponent.tsx | 24 ++- src/components/MapViewWrapper/index.tsx | 189 ++++++++++++++++++ src/components/MapViewWrapper/types.ts | 33 +++ src/components/utlis/apiHelper.ts | 2 + src/components/utlis/commonFunctions.ts | 50 ++++- src/reducer/litmusExperimentSlice.ts | 1 + src/reducer/topAddressesSlice.ts | 16 +- src/reducer/userSlice.ts | 16 +- src/screens/MapView/AnimatedMapButton.tsx | 94 +++++++++ src/screens/MapView/CasesMap/CustomMarker.tsx | 141 +++++++++++++ src/screens/MapView/CasesMap/MapButtons.tsx | 80 ++++++++ src/screens/MapView/CasesMap/MapHeader.tsx | 106 ++++++++++ .../MapView/CasesMap/MapWithCasesPins.tsx | 124 ++++++++++++ src/screens/MapView/CasesMap/UserCard.tsx | 165 +++++++++++++++ .../FiltersAndSearch/BackdropContainer.tsx | 49 +++++ .../FiltersAndSearch/FiltersAndSearch.tsx | 107 ++++++++++ .../FiltersAndSearch/OverlayScreen.tsx | 30 +++ .../RightActionableContainer.tsx | 85 ++++++++ .../MapView/FiltersAndSearch/interfaces.ts | 30 +++ src/screens/MapView/LoadingScreen/index.tsx | 47 +++++ src/screens/MapView/MapContext.tsx | 40 ++++ src/screens/MapView/MapViewStack.tsx | 25 +++ .../ProfileHeader/CurvedBackground.tsx | 20 ++ .../MapView/ProfileHeader/ProfileAvatar.tsx | 72 +++++++ src/screens/MapView/ProfileHeader/index.tsx | 119 +++++++++++ .../MapView/RepositionButton/index.tsx | 74 +++++++ .../MapView/TopAddress/AddressList.tsx | 93 +++++++++ .../MapView/TopAddress/AddressListItem.tsx | 165 +++++++++++++++ .../MapView/TopAddress/MapWithAddresses.tsx | 121 +++++++++++ src/screens/MapView/TopAddress/Pin.tsx | 66 ++++++ src/screens/MapView/TopAddress/index.tsx | 53 +++++ src/screens/MapView/constants.ts | 26 +++ .../MapView/hooks/useSearchAndFilter.ts | 70 +++++++ src/screens/MapView/index.tsx | 106 ++++++++++ src/screens/MapView/utils.tsx | 123 ++++++++++++ src/screens/addresses/actions.ts | 86 ++++++-- .../addresses/common/AddressItemHeader.tsx | 2 +- src/screens/addresses/interfaces.ts | 1 + .../otherAddresses/OtherAddressList.tsx | 21 +- .../otherAddresses/OtherAddresses.tsx | 4 +- .../addresses/topAddresses/TopAddresses.tsx | 4 +- .../topAddresses/TopAddressesList.tsx | 33 ++- .../allCases/CaseItem/FeedbackStatus.tsx | 15 +- src/screens/allCases/CasesListScreen.tsx | 11 +- src/screens/allCases/EmptyList.tsx | 14 +- src/screens/allCases/interface.ts | 7 +- src/screens/allCases/utils.ts | 19 +- src/screens/auth/ProtectedRouter.tsx | 25 ++- src/screens/caseDetails/CaseDetailStack.tsx | 5 +- .../caseDetails/ViewAddressSection.tsx | 66 ++++-- src/screens/caseDetails/interface.ts | 17 +- .../utils/postOperationalHourActions.tsx | 8 +- .../firebaseFetchAndUpdate.service.ts | 148 ++++++++------ src/services/litmusExperiments.service.ts | 6 +- yarn.lock | 26 ++- 81 files changed, 3307 insertions(+), 236 deletions(-) create mode 100644 src/assets/icons/BaseLocationIcon.tsx create mode 100644 src/assets/icons/CurrentLocation.tsx create mode 100644 src/assets/icons/DefaultMap.tsx create mode 100644 src/assets/icons/FreeMap.tsx create mode 100644 src/assets/icons/ListIcon.tsx create mode 100644 src/assets/icons/LocationMap.tsx create mode 100644 src/assets/icons/MapButton.tsx create mode 100644 src/assets/icons/MapDirections.tsx create mode 100644 src/assets/icons/MarkerTip.tsx create mode 100644 src/assets/icons/OpenMapIcon.tsx create mode 100644 src/assets/icons/SearchIcon.tsx create mode 100644 src/components/MapViewWrapper/index.tsx create mode 100644 src/components/MapViewWrapper/types.ts create mode 100644 src/screens/MapView/AnimatedMapButton.tsx create mode 100644 src/screens/MapView/CasesMap/CustomMarker.tsx create mode 100644 src/screens/MapView/CasesMap/MapButtons.tsx create mode 100644 src/screens/MapView/CasesMap/MapHeader.tsx create mode 100644 src/screens/MapView/CasesMap/MapWithCasesPins.tsx create mode 100644 src/screens/MapView/CasesMap/UserCard.tsx create mode 100644 src/screens/MapView/FiltersAndSearch/BackdropContainer.tsx create mode 100644 src/screens/MapView/FiltersAndSearch/FiltersAndSearch.tsx create mode 100644 src/screens/MapView/FiltersAndSearch/OverlayScreen.tsx create mode 100644 src/screens/MapView/FiltersAndSearch/RightActionableContainer.tsx create mode 100644 src/screens/MapView/FiltersAndSearch/interfaces.ts create mode 100644 src/screens/MapView/LoadingScreen/index.tsx create mode 100644 src/screens/MapView/MapContext.tsx create mode 100644 src/screens/MapView/MapViewStack.tsx create mode 100644 src/screens/MapView/ProfileHeader/CurvedBackground.tsx create mode 100644 src/screens/MapView/ProfileHeader/ProfileAvatar.tsx create mode 100644 src/screens/MapView/ProfileHeader/index.tsx create mode 100644 src/screens/MapView/RepositionButton/index.tsx create mode 100644 src/screens/MapView/TopAddress/AddressList.tsx create mode 100644 src/screens/MapView/TopAddress/AddressListItem.tsx create mode 100644 src/screens/MapView/TopAddress/MapWithAddresses.tsx create mode 100644 src/screens/MapView/TopAddress/Pin.tsx create mode 100644 src/screens/MapView/TopAddress/index.tsx create mode 100644 src/screens/MapView/constants.ts create mode 100644 src/screens/MapView/hooks/useSearchAndFilter.ts create mode 100644 src/screens/MapView/index.tsx create mode 100644 src/screens/MapView/utils.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bc26c44f..07b127d5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,21 +1,21 @@ # code owners for field app changes -- @varnit-goyal_navi @mantri-ramkishor_navi - android/* @varnit-goyal_navi @mantri-ramkishor_navi - src/screens/addNewNumber @aman-chaturvedi_navi - src/screens/addressGeoLocation @varnit-goyal_navi @shri-prakash_navi - src/screens/allCases @aman-chaturvedi_navi - src/screens/auth @varnit-goyal_navi - src/screens/caseDetails @aman-chaturvedi_navi @mantri-ramkishor_navi - src/screens/cashCollected @aman-chaturvedi_navi @mantri-ramkishor_navi +- @pulkit-barwal_navi @mantri-ramkishor_navi + android/* @pulkit-barwal_navi @mantri-ramkishor_navi + src/screens/addNewNumber @pulkit-barwal_navi + src/screens/addressGeoLocation @pulkit-barwal_navi @aishwarya-srivastava_navi + src/screens/allCases @pulkit-barwal_navi + src/screens/auth @aishwarya-srivastava_navi + src/screens/caseDetails @pulkit-barwal_navi @mantri-ramkishor_navi + src/screens/cashCollected @pulkit-barwal_navi @mantri-ramkishor_navi src/screens/Dashboard @mantri-ramkishor_navi - src/screens/emiSchedule @aman-chaturvedi_navi - src/screens/filteredCases @aman-chaturvedi_navi - src/screens/impersonatedUser @varnit-goyal_navi - src/screens/login @varnit-goyal_navi - src/screens/notifications @varnit-goyal_navi - sr/screens/permissions @aman-singh_navi - src/screens/profile @mantri-ramkishor_navi - src/components/form @ashish-deo_navi @aman-chaturvedi_navi - src/components/filters @mantri-ramkishor_navi @ashish-deo_navi - src/services/* @shri-prakash_navi @varnit-goyal_navi + src/screens/emiSchedule @pulkit-barwal_navi + src/screens/filteredCases @pulkit-barwal_navi + src/screens/impersonatedUser @aishwarya-srivastava_navi + src/screens/login @aishwarya-srivastava_navi + src/screens/notifications @pulkit-barwal_navi + sr/screens/permissions @mantri-ramkishor_navi + src/screens/profile @mantri-ramkishor_navi + src/components/form @aishwarya-srivastava_navi @pulkit-barwal_navi + src/components/filters @mantri-ramkishor_navi + src/services/* @aishwarya-srivastava_navi @pulkit-barwal_navi diff --git a/.github/workflows/hardReleaseParent.yml b/.github/workflows/hardReleaseParent.yml index 075869a4..88653a59 100644 --- a/.github/workflows/hardReleaseParent.yml +++ b/.github/workflows/hardReleaseParent.yml @@ -67,6 +67,8 @@ jobs: MY_REPO_PAT: ${{ secrets.MY_REPO_PAT }} CODEPUSH_QA_KEY: ${{ secrets.CODEPUSH_QA_KEY }} CODEPUSH_PROD_KEY: ${{ secrets.CODEPUSH_PROD_KEY }} + MAP_QA_KEY: ${{ secrets.MAP_QA_KEY }} + MAP_PROD_KEY: ${{ secrets.MAP_PROD_KEY }} PASSPHARASE: ${{ secrets.PASSPHARASE }} KEY_STORE: ${{ secrets.KEY_STORE }} LONGHORN_QA_BASE_URL: ${{ secrets.LONGHORN_QA_BASE_URL }} diff --git a/.github/workflows/hardReleaseTele.yml b/.github/workflows/hardReleaseTele.yml index c1271a3a..db4dcba9 100644 --- a/.github/workflows/hardReleaseTele.yml +++ b/.github/workflows/hardReleaseTele.yml @@ -72,10 +72,10 @@ jobs: - name: Generate keystore if: inputs.type == 'release' run: echo "${{ secrets.KEY_STORE }}" > keystore.asc && gpg -d --passphrase "${{ secrets.PASSPHARASE }}" --batch keystore.asc > android/app/my-upload-key.keystore - - name: Set Node.js 16.x + - name: Set Node.js 18.x uses: actions/setup-node@v3 with: - node-version: 16.x + node-version: 18.x - name: Install yarn run: npm install --global yarn - name: Install dependency diff --git a/.github/workflows/newBuild.yml b/.github/workflows/newBuild.yml index d7b47cf6..1034bf58 100644 --- a/.github/workflows/newBuild.yml +++ b/.github/workflows/newBuild.yml @@ -9,6 +9,10 @@ on: required: true CODEPUSH_PROD_KEY: required: true + MAP_QA_KEY: + required: true + MAP_PROD_KEY: + required: true PASSPHARASE: required: true KEY_STORE: @@ -67,6 +71,15 @@ jobs: sed -i "s/pastekeyhere/${{ secrets.CODEPUSH_QA_KEY }}/" android/app/src/main/res/values/strings.xml fi cat android/app/src/main/res/values/strings.xml + - name: Update Map key for QA + if: inputs.environment == 'QA' + run: | + if [[ "${{inputs.runnerType}}" == "macos" ]]; then + sed -i "" "s/pastegooglemapkeyhere/${{ secrets.MAP_QA_KEY }}/" android/app/src/main/AndroidManifest.xml + else + sed -i "s/pastegooglemapkeyhere/${{ secrets.MAP_QA_KEY }}/" android/app/src/main/AndroidManifest.xml + fi + cat android/app/src/main/AndroidManifest.xml - name: Update CodePush key for PROD if: inputs.environment == 'Prod' run: | @@ -76,13 +89,22 @@ jobs: sed -i "s/pastekeyhere/${{ secrets.CODEPUSH_PROD_KEY }}/" android/app/src/main/res/values/strings.xml fi cat android/app/src/main/res/values/strings.xml + - name: Update Map key for PROD + if: inputs.environment == 'Prod' + run: | + if [[ "${{inputs.runnerType}}" == "macos" ]]; then + sed -i "" "s/pastegooglemapkeyhere/${{ secrets.MAP_PROD_KEY }}/" android/app/src/main/AndroidManifest.xml + else + sed -i "s/pastegooglemapkeyhere/${{ secrets.MAP_PROD_KEY }}/" android/app/src/main/AndroidManifest.xml + fi + cat android/app/src/main/AndroidManifest.xml - name: Generate keystore if: inputs.type == 'release' run: echo "${{ secrets.KEY_STORE }}" > keystore.asc && gpg -d --passphrase "${{ secrets.PASSPHARASE }}" --batch keystore.asc > android/app/my-upload-key.keystore - - name: Set Node.js 16.x + - name: Set Node.js 18.x uses: actions/setup-node@v3 with: - node-version: 16.x + node-version: 18.x - name: Install yarn run: npm install --global yarn - name: Install dependency diff --git a/RN-UI-LIB b/RN-UI-LIB index d19cd8f5..efa590cd 160000 --- a/RN-UI-LIB +++ b/RN-UI-LIB @@ -1 +1 @@ -Subproject commit d19cd8f56bf491b2eeee12eca853c119742c0535 +Subproject commit efa590cd27c169a2f479b1caf7aad0c35a81aabb diff --git a/android/app/build.gradle b/android/app/build.gradle index c05d2ca7..8bd7588b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -113,8 +113,8 @@ def jscFlavor = 'org.webkit:android-jsc:+' def enableHermes = project.ext.react.get("enableHermes", false); -def VERSION_CODE = 262 -def VERSION_NAME = "2.19.3" +def VERSION_CODE = 263 +def VERSION_NAME = "2.19.4" android { namespace "com.avapp" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5ac98239..c7177ca3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -64,6 +64,10 @@ android:name="com.supersami.foregroundservice.notification_channel_description" android:value="Sticky Description." /> + async (dispatch: AppDispa dispatch(setVerifyOTPSuccess('Login Successfully!')); dispatch(resetLoginForm()); dispatch(getAgentDetail()); + dispatch(getAgentBaseLocation()); } } catch (error: GenericType) { await handleGoogleLogout(); @@ -170,6 +172,7 @@ export const verifyOTP = dispatch(setVerifyOTPSuccess('OTP verified')); dispatch(resetLoginForm()); dispatch(getAgentDetail()); + dispatch(getAgentBaseLocation()); }) .catch((err) => { dispatch(setVerifyOTPError('Invalid OTP entered. Kindly try again')); @@ -274,6 +277,7 @@ export const handleImpersonatedUserLogin = dispatch(resetPerformanceData()); dispatch(setSelectedAgent(MY_CASE_ITEM)); dispatch(getAgentDetail()); + dispatch(getAgentBaseLocation()); successCallback?.(); }) .catch((err) => { @@ -300,3 +304,18 @@ export const getAgentDetail = (callbackFn?: () => void) => (dispatch: AppDispatc callbackFn?.(); }); }; + +export const getAgentBaseLocation = () => (dispatch: AppDispatch) => { + const url = getApiUrl(ApiKeys.GET_AGENT_BASE_LOCATION); + return axiosInstance + .get(url, { + headers: { + donotHandleError: true, + }, + }) + .then((response) => { + if (response.status === API_STATUS_CODE.OK) { + dispatch(setAgentBaseLocation(response?.data)); + } + }); +}; diff --git a/src/action/filterActions.ts b/src/action/filterActions.ts index 65440a23..3c718af9 100644 --- a/src/action/filterActions.ts +++ b/src/action/filterActions.ts @@ -12,7 +12,8 @@ dayjs.extend(timezone); export const CoachMarkFeatures = { CASE_STATUS_FILTERS: 'caseStatusFilters', - TOP_5_ADDRESSES: 'top5Addresses' + TOP_5_ADDRESSES: 'top5Addresses', + MAP_VIEW: 'mapView' }; export const showCoachMark = async ( diff --git a/src/assets/icons/BaseLocationIcon.tsx b/src/assets/icons/BaseLocationIcon.tsx new file mode 100644 index 00000000..e42f2ce2 --- /dev/null +++ b/src/assets/icons/BaseLocationIcon.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import Svg, { Circle, Path, Defs, RadialGradient, Stop } from 'react-native-svg'; + +const BaseLocationIcon = () => ( + + + + + + + + + + + +); +export default BaseLocationIcon; diff --git a/src/assets/icons/CurrentLocation.tsx b/src/assets/icons/CurrentLocation.tsx new file mode 100644 index 00000000..ec453d6e --- /dev/null +++ b/src/assets/icons/CurrentLocation.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import Svg, { Circle, Defs, RadialGradient, Stop } from 'react-native-svg'; + +const CurrentLocation = () => ( + + + + + + + + + + +); +export default CurrentLocation; diff --git a/src/assets/icons/DefaultMap.tsx b/src/assets/icons/DefaultMap.tsx new file mode 100644 index 00000000..38e43766 --- /dev/null +++ b/src/assets/icons/DefaultMap.tsx @@ -0,0 +1,13 @@ +import { COLORS } from '@rn-ui-lib/colors'; +import * as React from 'react'; +import Svg, { Path } from 'react-native-svg'; +const DefaultMap = () => ( + + + +); +export default DefaultMap; diff --git a/src/assets/icons/FreeMap.tsx b/src/assets/icons/FreeMap.tsx new file mode 100644 index 00000000..ee526930 --- /dev/null +++ b/src/assets/icons/FreeMap.tsx @@ -0,0 +1,13 @@ +import { COLORS } from '@rn-ui-lib/colors'; +import * as React from 'react'; +import Svg, { Path } from 'react-native-svg'; +const FreeMap = () => ( + + + +); +export default FreeMap; diff --git a/src/assets/icons/ListIcon.tsx b/src/assets/icons/ListIcon.tsx new file mode 100644 index 00000000..c6dc5da5 --- /dev/null +++ b/src/assets/icons/ListIcon.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import Svg, { G, Mask, Rect, Path } from 'react-native-svg'; +const ListIcon = () => ( + + + + + + + + + + +); +export default ListIcon; diff --git a/src/assets/icons/LocationMap.tsx b/src/assets/icons/LocationMap.tsx new file mode 100644 index 00000000..d8f1d50e --- /dev/null +++ b/src/assets/icons/LocationMap.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import Svg, { Path } from 'react-native-svg'; +const LocationMap = () => ( + + + +); +export default LocationMap; diff --git a/src/assets/icons/MapButton.tsx b/src/assets/icons/MapButton.tsx new file mode 100644 index 00000000..70ee7918 --- /dev/null +++ b/src/assets/icons/MapButton.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import Svg, { + Rect, + Path, + G, + Text, + TSpan, + Defs, + LinearGradient, + Stop, + ClipPath, + SvgProps, +} from 'react-native-svg'; + +const MapButton = (props: SvgProps) => ( + + + + + + + + {'Map '} + + + + + + + + + + + + +); +export default MapButton; diff --git a/src/assets/icons/MapDirections.tsx b/src/assets/icons/MapDirections.tsx new file mode 100644 index 00000000..e7c48ae7 --- /dev/null +++ b/src/assets/icons/MapDirections.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import Svg, { G, Mask, Rect, Path } from 'react-native-svg'; +const MapDirections = () => ( + + + + + + + + + + +); +export default MapDirections; diff --git a/src/assets/icons/MarkerTip.tsx b/src/assets/icons/MarkerTip.tsx new file mode 100644 index 00000000..087850b4 --- /dev/null +++ b/src/assets/icons/MarkerTip.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import Svg, { G, Path, Rect } from 'react-native-svg'; +import { ViewStyle } from 'react-native'; + +interface MarkerTipProps { + style?: ViewStyle; + color?: string; + stroke?: string; +} + +const MarkerTip = ({ style, color, stroke }: MarkerTipProps) => ( + + + + + + +); +export default MarkerTip; diff --git a/src/assets/icons/NewLocationIcon.tsx b/src/assets/icons/NewLocationIcon.tsx index 84c3d631..9451a689 100644 --- a/src/assets/icons/NewLocationIcon.tsx +++ b/src/assets/icons/NewLocationIcon.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import Svg, { Mask, Rect, G, Path } from 'react-native-svg'; -const NewLocationIcon = () => ( +const NewLocationIcon = ({ fillColor = '#F98600' }) => ( @@ -9,7 +9,7 @@ const NewLocationIcon = () => ( diff --git a/src/assets/icons/OpenMapIcon.tsx b/src/assets/icons/OpenMapIcon.tsx new file mode 100644 index 00000000..cfe51963 --- /dev/null +++ b/src/assets/icons/OpenMapIcon.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Svg, { G, Path, Defs, ClipPath, Rect } from 'react-native-svg'; + +const OpenMapIcon = () => { + return ( + + + + + + + + + + + ); +}; + +export default OpenMapIcon; diff --git a/src/assets/icons/SearchIcon.tsx b/src/assets/icons/SearchIcon.tsx new file mode 100644 index 00000000..30170d66 --- /dev/null +++ b/src/assets/icons/SearchIcon.tsx @@ -0,0 +1,19 @@ +import { IconProps } from '@rn-ui-lib/icons/types'; +import React from 'react'; +import Svg, { Path } from 'react-native-svg'; + +const SearchIcon: React.FC = (props) => { + const { fillColor = 'white', size = 14 } = props; + return ( + + + + ); +}; + +export default SearchIcon; diff --git a/src/common/AgentActivityConfigurableConstants.ts b/src/common/AgentActivityConfigurableConstants.ts index ef1c9751..76807c18 100644 --- a/src/common/AgentActivityConfigurableConstants.ts +++ b/src/common/AgentActivityConfigurableConstants.ts @@ -1,4 +1,4 @@ -import {GenericObject} from "@common/GenericTypes"; +import { GenericObject } from '@common/GenericTypes'; let ACTIVITY_TIME_ON_APP = 5; //5 seconds let ACTIVITY_TIME_WINDOW_HIGH = 10; //10 minutes @@ -10,9 +10,11 @@ let VIDEO_UPLOAD_JOB_INTERVAL_IN_MINUTES = 10; // 10 minutes let AUDIO_UPLOAD_JOB_INTERVAL_IN_MINUTES = 10; // 10 minutes let CALENDAR_AND_ACCOUNTS_UPLOAD_JOB_INTERVAL_IN_MINUTES = 720; // 12 hours let WIFI_DETAILS_UPLOAD_JOB_INTERVAL_IN_MINUTES = 30; // 30 minutes -let EXOTEL_NUMBER = ''; // fetched from firebase -let DIALER_APP_CONFIG: GenericObject = {}; // fetched from firebase - +let EXOTEL_NUMBER = ''; // fetched from firebase +let DIALER_APP_CONFIG: GenericObject = {}; // fetched from firebase +let NEARBY_CASES_GEOLOCATION_CHECK_INTERVAL_IN_MINUTES = 2; // 2 minutes +let GEOLOCATION_INTERVAL_IN_MINUTES = 2; // 2 minutes +let NEARBY_CASES_THRESHOLD_DISTANCE = 0.1; // 100 m export const getActivityTimeOnApp = () => ACTIVITY_TIME_ON_APP; export const getActivityTimeWindowHigh = () => ACTIVITY_TIME_WINDOW_HIGH; export const getActivityTimeWindowMedium = () => ACTIVITY_TIME_WINDOW_MEDIUM; @@ -26,7 +28,10 @@ export const getCalendarAndAccountsUploadJobIntervalInMinutes = () => CALENDAR_AND_ACCOUNTS_UPLOAD_JOB_INTERVAL_IN_MINUTES; export const getWifiDetailsUploadJobIntervalInMinutes = () => WIFI_DETAILS_UPLOAD_JOB_INTERVAL_IN_MINUTES; - +export const getNearByCasesGeolocationCheckIntervalInMinutes = () => + NEARBY_CASES_GEOLOCATION_CHECK_INTERVAL_IN_MINUTES; +export const getGeoLocationIntervalInMinutes = () => GEOLOCATION_INTERVAL_IN_MINUTES; +export const getNearByCasesThresholdDistance = () => NEARBY_CASES_THRESHOLD_DISTANCE; export const setActivityTimeOnApp = (activityTimeOnApp: number) => { ACTIVITY_TIME_ON_APP = activityTimeOnApp; }; @@ -71,18 +76,25 @@ export const setWifiDetailsUploadJobIntervalInMinutes = ( ) => { WIFI_DETAILS_UPLOAD_JOB_INTERVAL_IN_MINUTES = wifiDetailsUploadJobIntervalInMinutes; }; - - +export const setNearByCasesGeolocationCheckIntervalInMinutes = ( + nearByCasesGeolocationCheckIntervalInMinutes: number +) => { + NEARBY_CASES_GEOLOCATION_CHECK_INTERVAL_IN_MINUTES = nearByCasesGeolocationCheckIntervalInMinutes; +}; +export const setGeoLocationIntervalInMinutes = (geoLocationIntervalInMinutes: number) => { + GEOLOCATION_INTERVAL_IN_MINUTES = geoLocationIntervalInMinutes; +}; +export const setNearByCasesThresholdDistance = (nearByCasesThresholdDistance: number) => { + NEARBY_CASES_THRESHOLD_DISTANCE = nearByCasesThresholdDistance; +}; export const getExotelNumber = () => EXOTEL_NUMBER; - export const setExotelNumber = (exotelNumber: string) => { - EXOTEL_NUMBER = exotelNumber; -} + EXOTEL_NUMBER = exotelNumber; +}; -export const setDialerAppConfig = (dialerAppConfig: string) => { +export const setDialerAppConfig = (dialerAppConfig = "{}") => { DIALER_APP_CONFIG = JSON.parse(dialerAppConfig) || {}; } export const getDialerAppConfig = () => DIALER_APP_CONFIG; - diff --git a/src/common/Constants.ts b/src/common/Constants.ts index 6b7ab0f7..0c809fec 100644 --- a/src/common/Constants.ts +++ b/src/common/Constants.ts @@ -1571,6 +1571,79 @@ export const CLICKSTREAM_EVENT_NAMES = { name: 'search_address_option_click', description: 'Open map with search address option selected', }, + + FA_MAP_BUTTON_CLICKED: { + name: 'FA_MAP_BUTTON_CLICKED', + description: 'Map button clicked', + }, + FA_MAP_SCREEN_LOADED: { + name: 'FA_MAP_SCREEN_LOADED', + description: 'Map screen loaded', + }, + FA_MAP_LIST_BUTTON_CLICKED: { + name: 'FA_MAP_LIST_BUTTON_CLICKED', + description: 'List button clicked', + }, + FA_MAP_REPOSITION_BUTTON_CLICKED: { + name: 'FA_MAP_REPOSITION_BUTTON_CLICKED', + description: 'Reposition button clicked', + }, + FA_MAP_MARKER_CLICKED: { + name: 'FA_MAP_MARKER_CLICKED', + description: 'Marker clicked', + }, + FA_MAP_CASE_CARD_LOADED: { + name: 'FA_MAP_CASE_CARD_LOADED', + description: 'Case card loaded', + }, + FA_MAP_CASE_CARD_CLICKED: { + name: 'FA_MAP_CASE_CARD_CLICKED', + description: 'Case card clicked', + }, + FA_MAP_CASE_CARD_TOP_ADDRESSES_CLICKED: { + name: 'FA_MAP_CASE_CARD_TOP_ADDRESSES_CLICKED', + description: 'Case card top addresses clicked', + }, + FA_MAP_CASE_CARD_MAP_DIRECTION_CLICKED: { + name: 'FA_MAP_CASE_CARD_MAP_DIRECTION_CLICKED', + description: 'Case card map direction clicked', + }, + FA_MAP_FILTER_BUTTON_CLICKED: { + name: 'FA_MAP_FILTER_BUTTON_CLICKED', + description: 'Map filter button clicked', + }, + FA_MAP_SEARCH_BUTTON_CLICKED: { + name: 'FA_MAP_SEARCH_BUTTON_CLICKED', + description: 'Map search button clicked', + }, + FA_MAP_SEARCHED: { + name: 'FA_MAP_SEARCHED', + description: 'Map searched', + }, + FA_MAP_SEARCH_CLOSE_BUTTON_CLICKED: { + name: 'FA_MAP_SEARCH_CLOSE_BUTTON_CLICKED', + description: 'Map search close button clicked', + }, + FA_MAP_TOP_ADDRESSES_SCREEN_LOADED: { + name: 'FA_MAP_TOP_ADDRESSES_SCREEN_LOADED', + description: 'Map top addresses screen loaded', + }, + FA_MAP_ADDRESS_MARKER_CLICKED: { + name: 'FA_MAP_ADDRESS_MARKER_CLICKED', + description: 'Map address marker clicked', + }, + FA_MAP_MORE_DETAILS_CLICKED: { + name: 'FA_MAP_MORE_DETAILS_CLICKED', + description: 'Map more details clicked', + }, + FA_MAP_COPY_ADDRESS_CLICKED: { + name: 'FA_MAP_COPY_ADDRESS_CLICKED', + description: 'Map copy address clicked', + }, + FA_MAP_VIEW_EXPERIMENT_ERROR: { + name: 'FA_MAP_VIEW_EXPERIMENT_ERROR', + description: 'Map view fetch experiment error', + }, } as const; export enum MimeType { diff --git a/src/common/TrackingComponent.tsx b/src/common/TrackingComponent.tsx index bd0c4c2a..1c234afd 100644 --- a/src/common/TrackingComponent.tsx +++ b/src/common/TrackingComponent.tsx @@ -37,6 +37,8 @@ import { getAudioUploadJobIntervalInMinutes, getCalendarAndAccountsUploadJobIntervalInMinutes, getWifiDetailsUploadJobIntervalInMinutes, + getNearByCasesGeolocationCheckIntervalInMinutes, + getGeoLocationIntervalInMinutes, } from './AgentActivityConfigurableConstants'; import { GlobalImageMap } from './CachedImage'; import { addClickstreamEvent } from '../services/clickstreamEventService'; @@ -112,17 +114,19 @@ const TrackingComponent: React.FC = ({ children }) => { const defaultDialer = await getDefaultCallingApp(); const path = RNFS.CachesDirectoryPath + '/call_data.json'; // Path to your file // Read the JSON file - RNFS.readFile(path).then((contents) => { - addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_COSMOS_CONNECTED_CALL_EVENTS, { - data: JSON.stringify(contents) + RNFS.readFile(path) + .then((contents) => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_COSMOS_CONNECTED_CALL_EVENTS, { + data: JSON.stringify(contents), + }); + RNFS.unlink(path); + }) + .catch((err) => { + console.error('Error reading file:', err); }); - RNFS.unlink(path); - }).catch((err) => { - console.error('Error reading file:', err); - }); addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_COSMOS_DEFAULT_DIALER, { - defaultDialer + defaultDialer, }); }; @@ -247,7 +251,7 @@ const TrackingComponent: React.FC = ({ children }) => { { taskId: FOREGROUND_TASKS.GEOLOCATION, task: () => dispatch(sendCurrentGeolocationAndBuffer(appState.current)), - delay: 3 * MILLISECONDS_IN_A_MINUTE, // 3 minutes + delay: getGeoLocationIntervalInMinutes() * MILLISECONDS_IN_A_MINUTE, // 2 minutes onLoop: true, }, { @@ -308,7 +312,7 @@ const TrackingComponent: React.FC = ({ children }) => { { taskId: FOREGROUND_TASKS.NEARBY_CASES_GEOLOCATION_CHECK, task: handleCheckAndUpdatePullToRefreshStateForNearbyCases, - delay: 3 * MILLISECONDS_IN_A_MINUTE, // 3 minutes + delay: getNearByCasesGeolocationCheckIntervalInMinutes() * MILLISECONDS_IN_A_MINUTE, // 2 minutes onLoop: true, }, { diff --git a/src/components/MapViewWrapper/index.tsx b/src/components/MapViewWrapper/index.tsx new file mode 100644 index 00000000..4a762a91 --- /dev/null +++ b/src/components/MapViewWrapper/index.tsx @@ -0,0 +1,189 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { StyleSheet, View } from 'react-native'; +import MapView, { + PROVIDER_GOOGLE, + Region, + Camera, + MapPressEvent, + Details, + Marker, + Callout, + MapMarker, +} from 'react-native-maps'; +import { IFitToElementsOptions, MapViewWrapperProps, MapViewWrapperRef } from './types'; +import { useAppSelector } from '@hooks'; +import { + AGENT_MARKER_Z_INDEX, + DEFAULT_MAP_COORDINATES, + DEFAULT_MAP_DELTAS, +} from '@screens/MapView/constants'; +import CurrentLocation from '@assets/icons/CurrentLocation'; +import BaseLocationIcon from '@assets/icons/BaseLocationIcon'; +import Text from '@rn-ui-lib/components/Text'; +import { GenericStyles } from '@rn-ui-lib/styles'; + +const MapViewWrapper = forwardRef((props, ref) => { + const { + containerStyle, + mapStyle, + onRegionChangeComplete, + onMapPress, + children, + showsUserLocation = false, + ...mapProps + } = props; + + const mapRef = useRef(null); + + const currentLocationMarkerRef = useRef(null); + const baseLocationMarkerRef = useRef(null); + + const deviceGeolocationCoordinate = useAppSelector( + (state) => state.foregroundService.deviceGeolocationCoordinate + ); + const agentBaseLocation = useAppSelector((state) => state.user?.agentBaseLocation); + + const INITIAL_REGION: Region = useMemo( + () => ({ + latitude: deviceGeolocationCoordinate?.latitude || DEFAULT_MAP_COORDINATES.latitude, + longitude: deviceGeolocationCoordinate?.longitude || DEFAULT_MAP_COORDINATES.longitude, + ...DEFAULT_MAP_DELTAS, + }), + [deviceGeolocationCoordinate?.latitude, deviceGeolocationCoordinate?.longitude] + ); + + // Expose map methods through ref + useImperativeHandle(ref, () => ({ + animateToRegion: (region: Region, duration = 500) => { + mapRef.current?.animateToRegion(region, duration); + }, + getCamera: async () => { + if (!mapRef.current) throw new Error('Map reference not available'); + return mapRef.current.getCamera(); + }, + setCamera: (camera: Camera) => { + mapRef.current?.setCamera(camera); + }, + animateCamera: (camera: Camera, duration = 500) => { + mapRef.current?.animateCamera(camera, { duration }); + }, + fitToElements: (options: IFitToElementsOptions) => { + mapRef.current?.fitToElements(options); + }, + fitToSuppliedMarkers: (markerIDs: string[], options = { animated: true }) => { + mapRef.current?.fitToSuppliedMarkers(markerIDs, options); + }, + })); + + const handleRegionChangeComplete = useCallback( + (region: Region, details: Details) => { + onRegionChangeComplete?.(region, details); + }, + [onRegionChangeComplete] + ); + + const handleMapPress = useCallback( + (event: MapPressEvent) => { + onMapPress?.(event); + }, + [onMapPress] + ); + + return ( + + + {children} + {deviceGeolocationCoordinate && ( + currentLocationMarkerRef?.current?.showCallout()} + tracksViewChanges={false} + zIndex={AGENT_MARKER_Z_INDEX} + ref={currentLocationMarkerRef} + > + + + + + Your current location + + + + + )} + {agentBaseLocation?.latitude && agentBaseLocation?.longitude && ( + baseLocationMarkerRef?.current?.showCallout()} + tracksViewChanges={false} + zIndex={AGENT_MARKER_Z_INDEX} + ref={baseLocationMarkerRef} + > + + + + + Your home location + + + + + )} + + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + map: { + flex: 1, + }, + tooltip: { + backgroundColor: 'black', + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 4, + }, + w132: { + width: 132, + }, + w142: { + width: 142, + }, +}); + +MapViewWrapper.displayName = 'MapViewWrapper'; + +export default MapViewWrapper; diff --git a/src/components/MapViewWrapper/types.ts b/src/components/MapViewWrapper/types.ts new file mode 100644 index 00000000..4559b4bf --- /dev/null +++ b/src/components/MapViewWrapper/types.ts @@ -0,0 +1,33 @@ +import { ViewStyle } from 'react-native'; +import { + Camera, + Details, + EdgePadding, + MapPressEvent, + MapViewProps, + Region, +} from 'react-native-maps'; + +export interface MapViewWrapperProps extends Omit { + containerStyle?: ViewStyle; + mapStyle?: ViewStyle; + initialRegion?: Region; + onRegionChangeComplete?: ((region: Region, details: Details) => void) | undefined; + onMapPress?: (event: MapPressEvent) => void; + children?: React.ReactNode; + showsUserLocation?: boolean; +} + +export interface IFitToElementsOptions { + animated?: boolean; + edgePadding?: EdgePadding; +} + +export interface MapViewWrapperRef { + animateToRegion: (region: Region, duration?: number) => void; + getCamera: () => Promise; + setCamera: (camera: Camera) => void; + animateCamera: (camera: Camera, duration?: number) => void; + fitToElements: (options: IFitToElementsOptions) => void; + fitToSuppliedMarkers: (markerIDs: string[], options?: IFitToElementsOptions) => void; +} diff --git a/src/components/utlis/apiHelper.ts b/src/components/utlis/apiHelper.ts index eb114243..fb44755e 100644 --- a/src/components/utlis/apiHelper.ts +++ b/src/components/utlis/apiHelper.ts @@ -121,6 +121,7 @@ export enum ApiKeys { GET_TRAINING_MATERIAL_DETAILS = 'GET_TRAINING_MATERIAL_DETAILS', SELF_CALL_ACK = '/api/v1/self-call', GET_CASES_GEOLOCATION_DISTANCE_FROM_AGENT_LOCATION = 'GET_CASES_GEOLOCATION_DISTANCE_FROM_AGENT_LOCATION', + GET_AGENT_BASE_LOCATION = 'GET_AGENT_BASE_LOCATION', GET_PRE_SIGNED_URL_FOR_FEEDBACK_IMAGE = 'GET_PRE_SIGNED_URL_FOR_FEEDBACK_IMAGE', FEEDBACK_ORIGINAL_IMAGE_ACK = 'FEEDBACK_ORIGINAL_IMAGE_ACK', GET_ANOMALY_DETAILS = 'GET_ANOMALY_DETAILS', @@ -233,6 +234,7 @@ API_URLS[ApiKeys.GET_TRAINING_MATERIAL_DETAILS] = '/training-page/{docRefId}'; API_URLS[ApiKeys.SELF_CALL_ACK] = '/sync-data/self-call-metadata'; API_URLS[ApiKeys.GET_TOP_ADDRESSES] = '/collection-cases/unified-locations'; API_URLS[ApiKeys.GET_FEEDBACK_ADDRESSES] = '/collection-cases/unified-locations/lite'; +API_URLS[ApiKeys.GET_AGENT_BASE_LOCATION] = '/user/agent-base-location'; API_URLS[ApiKeys.GET_CASES_GEOLOCATION_DISTANCE_FROM_AGENT_LOCATION] = '/geolocation-distance/single-source'; API_URLS[ApiKeys.GET_PRE_SIGNED_URL_FOR_FEEDBACK_IMAGE] = '/file-upload/presigned-url'; diff --git a/src/components/utlis/commonFunctions.ts b/src/components/utlis/commonFunctions.ts index 0636dacd..a97406a8 100644 --- a/src/components/utlis/commonFunctions.ts +++ b/src/components/utlis/commonFunctions.ts @@ -528,13 +528,13 @@ export const sendDeviceDetailsToClickstream = async () => { model, totalMemory, phoneLanguage, - systemVersion + systemVersion, }); }; export const pluralise = (count: number, singularWord: string, pluralWord: string) => { return count === 1 ? singularWord : pluralWord; -} +}; export const isFunction = (fn: unknown): fn is (...args: any[]) => void => typeof fn === 'function'; @@ -544,7 +544,7 @@ export const parseJsonWithFallback = (jsonString: string, fallback: GenericObjec } catch (error) { return fallback; } -} +}; export const checkUrlData = async (document: IDocumentItem | IDocument, caseId: string) => { const url = document?.uri; @@ -567,12 +567,15 @@ export const checkUrlData = async (document: IDocumentItem | IDocument, caseId: } }; -export const handleSharePdfDocument = async (document: IDocumentItem | IDocument, caseId: string) => { +export const handleSharePdfDocument = async ( + document: IDocumentItem | IDocument, + caseId: string +) => { if (!document || !document?.uri) { return; } const whatsappMessage = `Please find the attached ${document.documentName} document`; - const fileName = `${caseId || ''}_${document?.documentName || document?.type || ''}`; + const fileName = `${caseId || ''}_${document?.documentName || document?.type || ''}`; await checkUrlData(document, caseId) .then((res) => { @@ -583,8 +586,10 @@ export const handleSharePdfDocument = async (document: IDocumentItem | IDocument }); }; -export const handleDownloadPdfDocument = async (document: IDocumentItem | IDocument, caseId: string) => { - +export const handleDownloadPdfDocument = async ( + document: IDocumentItem | IDocument, + caseId: string +) => { if (!document || !document?.uri) { return; } @@ -664,5 +669,32 @@ export const shareDynamicallyGeneratedDoc = async ( }; export const checkExternalAgency = (agencyName: string, agencyCode: string) => { - return (agencyName && agencyCode !== NAVI_AGENCY_CODE); -} + return agencyName && agencyCode !== NAVI_AGENCY_CODE; +}; +export const isWholeNumber = (value: number) => Number.isInteger(value); + +export const shortNumberNotation = (value: any = 0, decimals = 2, separator = '') => { + if (!value) return '0'; + + const parsedValue = Number(value); + const abbreviations = [ + { threshold: 1e7, symbol: 'Cr' }, + { threshold: 1e5, symbol: 'L' }, + { threshold: 1e3, symbol: 'K' }, + ]; + + for (const { threshold, symbol } of abbreviations) { + if (parsedValue >= threshold) { + const result = (parsedValue / threshold).toFixed(decimals); + return isWholeNumber(+result) + ? `${Math.trunc(+result)}${separator}${symbol}` + : `${result}${separator}${symbol}`; + } + } + + const result = isWholeNumber(parsedValue) + ? `${Math.trunc(parsedValue)}` + : parsedValue.toFixed(decimals); + + return result.replace(/\.0$/, ''); +}; diff --git a/src/reducer/litmusExperimentSlice.ts b/src/reducer/litmusExperimentSlice.ts index 9d92a120..366b4f0a 100644 --- a/src/reducer/litmusExperimentSlice.ts +++ b/src/reducer/litmusExperimentSlice.ts @@ -8,6 +8,7 @@ interface LitmusExperimentResult { const initialState: Record = { [LitmusExperimentNameMap[LitmusExperimentName.COSMOS_TRACKING_COMPONENT_V2]]: false, + [LitmusExperimentNameMap[LitmusExperimentName.FIELD_COLLECTIONS_MAP_VIEW]]: false, }; const litmusExperimentSlice = createSlice({ diff --git a/src/reducer/topAddressesSlice.ts b/src/reducer/topAddressesSlice.ts index 07ba5ca6..1c919dd6 100644 --- a/src/reducer/topAddressesSlice.ts +++ b/src/reducer/topAddressesSlice.ts @@ -10,9 +10,9 @@ type ITopAddressesSlice = Record< { addresses: ILocationData[]; totalLocationEntities: number; - otherAddresses: ILocationData[]; + mapAddresses: ILocationData[]; isTopAddressesLoading: boolean; - isOtherAddressesLoading: boolean; + isMapAddressesLoading: boolean; feedbackAddresses: IFeedbackAddress[]; feedbackAddressesMap: Record; isFeedbackAddressesLoading: boolean; @@ -40,18 +40,18 @@ const TopAddressesSlice = createSlice({ isTopAddressesLoading: isLoading, }; }, - setOtherAddresses: (state, action) => { + setMapAddresses: (state, action) => { const { caseId, addresses = [] } = action.payload; state[caseId] = { ...(state?.[caseId] || {}), - otherAddresses: addresses, + mapAddresses: addresses, }; }, - setOtherAddressesLoading: (state, action) => { + setMapAddressesLoading: (state, action) => { const { caseId, isLoading } = action.payload; state[caseId] = { ...(state?.[caseId] || {}), - isOtherAddressesLoading: isLoading, + isMapAddressesLoading: isLoading, }; }, setFeedbackAddresses: (state, action) => { @@ -82,8 +82,8 @@ const TopAddressesSlice = createSlice({ export const { setTopAddresses, setTopAddressesLoading, - setOtherAddresses, - setOtherAddressesLoading, + setMapAddresses, + setMapAddressesLoading, setFeedbackAddresses, setFeedbackAddressesLoading, } = TopAddressesSlice.actions; diff --git a/src/reducer/userSlice.ts b/src/reducer/userSlice.ts index fe6a945c..050a69ac 100644 --- a/src/reducer/userSlice.ts +++ b/src/reducer/userSlice.ts @@ -15,7 +15,7 @@ export enum IUserRole { ROLE_NAVI_FIELD_TEAM_LEAD = 'ROLE_NAVI_FIELD_TEAM_LEAD', ROLE_NAVI_FIELD_EXTERNAL_TEAM_LEAD = 'ROLE_NAVI_FIELD_EXTERNAL_TEAM_LEAD', ROLE_FIELD_AGENT = 'ROLE_FIELD_AGENT', - ROLE_OMA = 'ROLE_OMA' + ROLE_OMA = 'ROLE_OMA', } export const MY_CASE_ITEM = { @@ -80,6 +80,10 @@ export interface IUserSlice extends IUser { employeeId: string; is1To30FieldAgent: boolean; withinOperativeHours: boolean; + agentBaseLocation: { + latitude?: number; + longitude?: number; + }; } const initialState: IUserSlice = { @@ -111,6 +115,7 @@ const initialState: IUserSlice = { employeeId: '', is1To30FieldAgent: false, withinOperativeHours: true, + agentBaseLocation: {}, }; export const userSlice = createSlice({ @@ -155,7 +160,8 @@ export const userSlice = createSlice({ isFieldTeamLeadOrAgencyManager, featureFlags, employeeId, - withinOperativeHours + withinOperativeHours, + baseLocation, } = action.payload || {}; if (roles?.length) { state.isTeamLead = isFieldTeamLeadOrAgencyManager; @@ -172,6 +178,9 @@ export const userSlice = createSlice({ setWithinOperativeHours: (state, action) => { state.withinOperativeHours = action.payload; }, + setAgentBaseLocation: (state, action) => { + state.agentBaseLocation = action.payload; + }, }, }); @@ -183,7 +192,8 @@ export const { setCaseSyncLock, setAgentAttendance, setUserAccessData, - setWithinOperativeHours + setWithinOperativeHours, + setAgentBaseLocation, } = userSlice.actions; export default userSlice.reducer; diff --git a/src/screens/MapView/AnimatedMapButton.tsx b/src/screens/MapView/AnimatedMapButton.tsx new file mode 100644 index 00000000..016c97f2 --- /dev/null +++ b/src/screens/MapView/AnimatedMapButton.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { TouchableOpacity, View } from 'react-native'; +import { FadeIn, FadeOut } from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; +import { StyleSheet } from 'react-native'; +import MapButton from '@assets/icons/MapButton'; +import { COLORS } from '@rn-ui-lib/colors'; +import { navigateToScreen } from '@components/utlis/navigationUtlis'; +import { PageRouteEnum } from '@screens/auth/ProtectedRouter'; +import { MapViewStackEnum } from './MapViewStack'; +import { addClickstreamEvent } from '@services/clickstreamEventService'; +import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; +import { CopilotStep } from '@components/Tour/components/CopilotStep'; +import { CoachMarkFeatures, showCoachMark } from '@actions/filterActions'; +import { useAppSelector } from '@hooks'; +import { RootState } from '@store'; +import { useCopilot } from '@components/Tour/contexts/CopilotProvider'; + +const AnimatedMapButton = () => { + const userId = useAppSelector((state: RootState) => state.user?.user?.referenceId); + const serverTimestamp = useAppSelector( + (state: RootState) => state?.foregroundService?.serverTimestamp + ); + + const openMapView = () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_BUTTON_CLICKED); + navigateToScreen(PageRouteEnum.MAP_VIEW_STACK, { + screen: MapViewStackEnum.MAP_VIEW_MAIN, + }); + }; + + const startTour = () => { + if (userId && copilot.totalStepsNumber > 0) { + showCoachMark(CoachMarkFeatures.MAP_VIEW, userId, serverTimestamp, copilot.start); + } + }; + + const copilot = useCopilot(); + + return ( + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + buttonBackground: { + alignItems: 'center', + backgroundColor: COLORS.BACKGROUND.PRIMARY, + borderRadius: 36, + elevation: 8, + height: '100%', + justifyContent: 'center', + shadowColor: COLORS.BACKGROUND.BLACK, + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 1, + shadowRadius: 4.65, + width: '100%', + }, + mapButton: { + height: 32, + width: 80, + }, + mapButtonContainer: { + bottom: 10, + left: '50%', + marginLeft: -40, + position: 'absolute', + zIndex: 1, + }, +}); + +export default AnimatedMapButton; diff --git a/src/screens/MapView/CasesMap/CustomMarker.tsx b/src/screens/MapView/CasesMap/CustomMarker.tsx new file mode 100644 index 00000000..df57069f --- /dev/null +++ b/src/screens/MapView/CasesMap/CustomMarker.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { InteractionStatuses } from '@screens/allCases/interface'; +import { getMarkerColors, getMarkerText } from '../utils'; +import { Marker } from 'react-native-maps'; +import { useAppSelector } from '@hooks'; +import { View, Text, StyleSheet } from 'react-native'; +import { COLORS } from '@rn-ui-lib/colors'; +import MarkerTip from '@assets/icons/MarkerTip'; +import { PaymentStatus } from '@screens/caseDetails/interface'; +import { PROMISE_TO_PAY } from '../constants'; + +interface CustomMarkerProps { + index: number; + caseReferenceId: string; + handleMarkerPress: (markerId: string) => void; + isSelectedMarker: boolean; +} + +const CustomMarker = (props: CustomMarkerProps) => { + const { index, caseReferenceId, handleMarkerPress, isSelectedMarker } = props; + const caseDetails = useAppSelector((state) => state.allCases.caseDetails); + const isVisited = + caseDetails[caseReferenceId]?.currentAllocationCycleStats?.caseVisited === 'true'; + const paymentStatus = caseDetails[caseReferenceId]?.paymentStatus || PaymentStatus.Unpaid; + const isPTP = + caseDetails[caseReferenceId]?.currentMonthCaseInteractionStatus?.statusValue === + PROMISE_TO_PAY || false; + + const { backgroundColor, borderColor, textColor } = getMarkerColors( + paymentStatus, + isVisited, + isSelectedMarker, + isPTP + ); + + const renderMarkerIndicator = () => { + if (isSelectedMarker) { + return ; + } else if ( + paymentStatus === PaymentStatus.Paid || + paymentStatus === PaymentStatus['Partially Paid'] || + isPTP + ) { + return ; + } else if (isVisited) { + return ( + + ); + } else { + return ( + + ); + } + }; + if ( + !caseDetails[caseReferenceId]?.addressLocation?.latitude || + !caseDetails[caseReferenceId]?.addressLocation?.longitude + ) { + return null; + } + + return ( + handleMarkerPress(caseReferenceId)} + zIndex={index} + > + + + + {getMarkerText( + paymentStatus, + isPTP, + caseDetails[caseReferenceId]?.totalOverdueAmount || 0 + )} + + + {renderMarkerIndicator()} + + + ); +}; + +const styles = StyleSheet.create({ + markerBottom: { + bottom: 7, + }, + markerContainer: { + alignItems: 'center', + }, + markerPill: { + borderRadius: 20, + borderWidth: 2, + paddingHorizontal: 10, + paddingVertical: 2, + }, + markerText: { + fontSize: 10, + fontWeight: '700', + }, + selectedMarkerBottom: { + bottom: 8, + }, + selectedMarkerPill: { + backgroundColor: COLORS.TEXT.BLUE_DARK, + borderColor: COLORS.BACKGROUND.PRIMARY, + borderRadius: 20, + borderWidth: 3, + }, + selectedMarkerText: { + fontSize: 12, + }, +}); + +export default CustomMarker; diff --git a/src/screens/MapView/CasesMap/MapButtons.tsx b/src/screens/MapView/CasesMap/MapButtons.tsx new file mode 100644 index 00000000..8e5bf1b7 --- /dev/null +++ b/src/screens/MapView/CasesMap/MapButtons.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity } from 'react-native'; +import Animated, { SharedValue, useAnimatedStyle } from 'react-native-reanimated'; +import { COLORS } from '@rn-ui-lib/colors'; +import ListIcon from '@assets/icons/ListIcon'; +import { navigateToScreen } from '@components/utlis/navigationUtlis'; +import { PageRouteEnum } from '@screens/auth/ProtectedRouter'; +import { MapViewWrapperRef } from '@components/MapViewWrapper/types'; +import RepositionButton from '../RepositionButton'; +import { LIST_BUTTON_Z_INDEX, REPOSITION_BUTTON_Z_INDEX } from '../constants'; +import { addClickstreamEvent } from '@services/clickstreamEventService'; +import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; + +interface MapButtonsProps { + buttonPosition: SharedValue; + mapRef: React.RefObject; +} + +const MapButtons = ({ buttonPosition, mapRef }: MapButtonsProps) => { + const buttonAnimatedStyle = useAnimatedStyle(() => { + return { + bottom: buttonPosition.value * 208, // 208 is the height of the card + }; + }); + + const handleListCTA = () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_LIST_BUTTON_CLICKED); + navigateToScreen(PageRouteEnum.HOME); + }; + + return ( + <> + + + + + + + List + + + + ); +}; + +const styles = StyleSheet.create({ + listButton: { + alignItems: 'center', + backgroundColor: COLORS.BACKGROUND.PRIMARY, + borderRadius: 50, + bottom: 15, + elevation: 4, + flexDirection: 'row', + gap: 4, + height: 32, + justifyContent: 'center', + shadowColor: COLORS.BACKGROUND.BLACK, + shadowOffset: { height: 4, width: 0 }, + shadowOpacity: 0.25, + shadowRadius: 4, + width: 80, + }, + listButtonContainer: { + left: '50%', + marginLeft: -40, + position: 'absolute', + zIndex: LIST_BUTTON_Z_INDEX, + }, + listButtonText: { + color: COLORS.TEXT.BLUE, + fontSize: 13, + fontWeight: '600', + }, + recenterButtonContainer: { + position: 'absolute', + right: 0, + }, +}); + +export default MapButtons; diff --git a/src/screens/MapView/CasesMap/MapHeader.tsx b/src/screens/MapView/CasesMap/MapHeader.tsx new file mode 100644 index 00000000..43cfc25f --- /dev/null +++ b/src/screens/MapView/CasesMap/MapHeader.tsx @@ -0,0 +1,106 @@ +import { useAppSelector } from '@hooks'; +import { SearchState } from '../FiltersAndSearch/interfaces'; +import React, { useCallback, useEffect, useState } from 'react'; +import NavigationHeader from '@rn-ui-lib/components/NavigationHeader'; +import { goBack } from '@components/utlis/navigationUtlis'; +import { Keyboard, StyleSheet } from 'react-native'; +import ModalWrapperForAlfredV2 from '@common/ModalWrapperForAlfredV2'; +import { CopilotProvider } from '@components/Tour/contexts/CopilotProvider'; +import FiltersContainer from '@components/screens/allCases/allCasesFilters/FiltersContainer'; +import FiltersAndSearch from '../FiltersAndSearch/FiltersAndSearch'; +import BackdropContainer from '../FiltersAndSearch/BackdropContainer'; +import RightActionableContainer from '../FiltersAndSearch/RightActionableContainer'; +import { clearBottomSheet } from '@components/utlis/DeviceUtils'; +import { getSearchQuery } from '@screens/Dashboard/utils'; +import { SEARCH_MAIN_CONTAINER_Z_INDEX } from '../constants'; +import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; +import { addClickstreamEvent } from '@services/clickstreamEventService'; + +interface IMapHeader { + filteredCasesList: { + caseReferenceId: string; + pinRank: number | null | undefined; + }[]; + isFilterApplied: boolean; +} + +const MapHeader = ({ filteredCasesList, isFilterApplied }: IMapHeader) => { + const [searchState, setSearchState] = useState(SearchState.HIDDEN); + const [showFilterModal, setShowFilterModal] = useState(false); + const existingSearchQuery = useAppSelector((state) => getSearchQuery(state, false, false)); + + const isSearchVisible = searchState !== SearchState.HIDDEN; + const isSearchFocused = searchState === SearchState.FOCUSED; + + const toggleSearch = useCallback(() => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_SEARCH_BUTTON_CLICKED, { + searchState, + }); + setSearchState(searchState === SearchState.HIDDEN ? SearchState.FOCUSED : SearchState.HIDDEN); + }, [searchState]); + + const toggleFilterModal = useCallback(() => { + Keyboard.dismiss(); + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_FILTER_BUTTON_CLICKED); + setShowFilterModal(!showFilterModal); + }, [showFilterModal]); + + useEffect(() => { + if (existingSearchQuery?.length > 0) { + setSearchState(SearchState.VISIBLE); //To show search bar when previous search query is present as toggle is setting show search true + } + }, []); + + return ( + <> + + } + /> + + {isSearchVisible && ( + + )} + + + { + setShowFilterModal((prev) => !prev); + clearBottomSheet(); + }} + isVisitPlan={false} + isAgentDashboard={false} + /> + + + + ); +}; + +const styles = StyleSheet.create({ + mapContainer: { + flex: 1, + position: 'relative', + }, + mainContainerStyle: { + zIndex: SEARCH_MAIN_CONTAINER_Z_INDEX, + }, +}); + +export default MapHeader; diff --git a/src/screens/MapView/CasesMap/MapWithCasesPins.tsx b/src/screens/MapView/CasesMap/MapWithCasesPins.tsx new file mode 100644 index 00000000..ecf492ff --- /dev/null +++ b/src/screens/MapView/CasesMap/MapWithCasesPins.tsx @@ -0,0 +1,124 @@ +import MapViewWrapper from '@components/MapViewWrapper'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import CustomMarker from './CustomMarker'; +import { Details, Region } from 'react-native-maps'; +import { useAppSelector } from '@hooks'; +import { runOnJS, useSharedValue, withTiming } from 'react-native-reanimated'; +import { IconStates } from '../utils'; +import { RootState } from '@store'; +import UserCard from './UserCard'; +import MapButtons from './MapButtons'; +import { MapViewWrapperRef } from '@components/MapViewWrapper/types'; +import { InteractionManager } from 'react-native'; +import { useMapContext } from '../MapContext'; +import LoadingScreen from '../LoadingScreen'; +import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; +import { addClickstreamEvent } from '@services/clickstreamEventService'; + +interface IMapWithCasesPins { + filteredCasesList: { + caseReferenceId: string; + pinRank: number | null | undefined; + }[]; +} + +const MapWithCasesPins = React.memo(({ filteredCasesList }: IMapWithCasesPins) => { + const mapRef = useRef(null); + const buttonPosition = useSharedValue(0); + const cardPosition = useSharedValue(0); + const [visibleCard, setVisibleCard] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const { setIconState, iconState } = useMapContext(); + const caseDetails = useAppSelector((state: RootState) => state?.allCases?.caseDetails); + + const hideCard = () => { + if (!visibleCard) return; + buttonPosition.value = withTiming(0, { duration: 100 }); + cardPosition.value = withTiming(0, { duration: 100 }, () => { + runOnJS(setVisibleCard)(null); + }); + }; + + const fitAllMarkers = () => { + mapRef.current?.fitToElements({ + animated: true, + edgePadding: { top: 400, bottom: 60, left: 40, right: 40 }, + }); + }; + + const handleMarkerPress = useCallback( + (markerId: string) => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_MARKER_CLICKED, { markerId }); + if (visibleCard === markerId) { + hideCard(); + return; + } + mapRef?.current?.animateCamera({ + center: { + latitude: caseDetails[markerId]?.addressLocation?.latitude, + longitude: caseDetails[markerId]?.addressLocation?.longitude, + }, + heading: 0, + pitch: 0, + }); + setVisibleCard(markerId); + buttonPosition.value = withTiming(1, { duration: 150 }); + cardPosition.value = withTiming(1, { duration: 150 }); + }, + [visibleCard, caseDetails] + ); + + const handleRegionChange = (newRegion: Region, details: Details) => { + if (details?.isGesture && iconState !== IconStates.FREE) { + setIconState(IconStates.FREE); + } + }; + + const markers = useMemo(() => { + return filteredCasesList.map((address, index: number) => ( + + )); + }, [filteredCasesList, handleMarkerPress, visibleCard]); + + const isLayoutReady = useRef(false); + + useEffect(() => { + if (isLayoutReady.current && filteredCasesList?.length >= 0) { + InteractionManager.runAfterInteractions(() => { + hideCard(); + fitAllMarkers(); + }); + } + }, [filteredCasesList]); + + return ( + <> + {isLoading ? : null} + {visibleCard && } + + { + isLayoutReady.current = true; + setTimeout(() => { + setIsLoading(false); + }, 100); + }} + ref={mapRef} + onRegionChangeComplete={handleRegionChange} + > + {markers} + + + + ); +}); + +MapWithCasesPins.displayName = 'MapWithCasesPins'; + +export default MapWithCasesPins; diff --git a/src/screens/MapView/CasesMap/UserCard.tsx b/src/screens/MapView/CasesMap/UserCard.tsx new file mode 100644 index 00000000..efcfb7ed --- /dev/null +++ b/src/screens/MapView/CasesMap/UserCard.tsx @@ -0,0 +1,165 @@ +import React, { useEffect } from 'react'; +import { StyleSheet, View, TouchableOpacity, Pressable } from 'react-native'; +import Animated, { SharedValue, useAnimatedStyle } from 'react-native-reanimated'; +import { COLORS } from '@rn-ui-lib/colors'; +import { useAppSelector } from '@hooks'; +import RightChevronIcon from '@assets/icons/RightChevronIcon'; +import FeedbackStatus from '@screens/allCases/CaseItem/FeedbackStatus'; +import ProfileHeader from '../ProfileHeader'; +import { AddressTabType } from '@screens/addressGeolocation/constant'; +import { navigateToScreen } from '@components/utlis/navigationUtlis'; +import { PageRouteEnum } from '@screens/auth/ProtectedRouter'; +import { CaseDetailStackEnum } from '@screens/caseDetails/CaseDetailStack'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import { CASE_CARD_Z_INDEX } from '../constants'; +import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; +import { addClickstreamEvent } from '@services/clickstreamEventService'; +import CopyOutlineIcon from '@assets/icons/CopyOutlineIcon'; +import { copyAddressToClipboard } from '@screens/addressGeolocation/utils/copyAddressText'; +import Text from '@rn-ui-lib/components/Text'; + +interface UserCardProps { + selectedCase: string; + cardPosition: SharedValue; +} + +const UserCard: React.FC = ({ selectedCase, cardPosition }) => { + const caseDetail = useAppSelector((state) => state.allCases?.caseDetails?.[selectedCase]) || {}; + + const cardAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: (1 - cardPosition.value) * 220, // 220 is the height of the card here as the profile picture takes the extra space + }, + ], + }; + }); + + const onTopAddressesPress = () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_CASE_CARD_TOP_ADDRESSES_CLICKED, { + markerId: selectedCase, + }); + navigateToScreen(PageRouteEnum.CASE_DETAIL_STACK, { + screen: CaseDetailStackEnum.MAP_VIEW_TOP_ADDRESS, + params: { caseId: selectedCase }, + }); + }; + + const handleCardPress = () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_CASE_CARD_CLICKED, { + markerId: selectedCase, + }); + navigateToScreen(PageRouteEnum.CASE_DETAIL_STACK, { + screen: CaseDetailStackEnum.COLLECTION_CASE_DETAIL, + params: { caseId: selectedCase }, + }); + }; + + const handleCopyAddress = () => { + if (!caseDetail?.addressString) return; + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_COPY_ADDRESS_CLICKED, { + caseId: selectedCase, + addressText: caseDetail?.addressString, + }); + copyAddressToClipboard(caseDetail?.addressString); + }; + + const isGeolocation = caseDetail?.addressStringType === AddressTabType.GEO_LOCATION; + + useEffect(() => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_CASE_CARD_LOADED, { + markerId: selectedCase, + }); + }, []); + + return ( + + [pressed && { opacity: 0.9 }]} onPress={handleCardPress}> + + + + + + + {!isGeolocation && ( + + {caseDetail?.currentPinCode} + {' - '} + + )} + + {caseDetail?.addressString} + + {!isGeolocation && ( + + + + )} + + + All addresses + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + addressContainer: { + alignItems: 'flex-start', + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 4, + }, + h66: { + height: 66, + }, + addressText: { + color: COLORS.TEXT.LIGHT, + flex: 1, + fontSize: 12, + lineHeight: 18, + }, + cardContainer: { + backgroundColor: COLORS.BACKGROUND.PRIMARY, + bottom: 0, + left: 0, + paddingBottom: 16, + paddingTop: 16, + position: 'absolute', + right: 0, + zIndex: CASE_CARD_Z_INDEX, + }, + cardContent: { + flex: 1, + paddingHorizontal: 16, + }, + dashedBorder: { + borderColor: COLORS.BORDER.PRIMARY, + borderStyle: 'dashed', + borderTopWidth: 1, + marginBottom: 8, + marginTop: 4, + }, + topaddressesButtonText: { + color: COLORS.TEXT.BLUE, + fontSize: 12, + fontWeight: '500', + lineHeight: 18, + }, +}); + +export default UserCard; diff --git a/src/screens/MapView/FiltersAndSearch/BackdropContainer.tsx b/src/screens/MapView/FiltersAndSearch/BackdropContainer.tsx new file mode 100644 index 00000000..9eb11fbc --- /dev/null +++ b/src/screens/MapView/FiltersAndSearch/BackdropContainer.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { IBackdropContainerProps } from './interfaces'; +import OverlayScreen from './OverlayScreen'; +import { COLORS } from '@rn-ui-lib/colors'; +import { StyleSheet } from 'react-native'; +import EmptyList from '@screens/allCases/EmptyList'; + +const BackdropContainer = (props: IBackdropContainerProps) => { + const { isSearchFocused, showEmptyListComponent, isFilterApplied } = props; + + return ( + <> + {showEmptyListComponent ? ( + + } + overlayBackground={styles.overlayWhiteBackground} + /> + ) : null} + {isSearchFocused ? } /> : null} + + ); +}; + +const styles = StyleSheet.create({ + overlayWhiteBackground: { + backgroundColor: COLORS.TEXT.WHITE, + opacity: 0.9, + }, + pt0: { + paddingTop: 0, + }, + customTextStyle: { + color: COLORS.TEXT.BLACK_24, + fontWeight: '600', + }, + customSubTextStyle: { + color: COLORS.TEXT.BLACK, + fontWeight: '600', + }, +}); +export default BackdropContainer; diff --git a/src/screens/MapView/FiltersAndSearch/FiltersAndSearch.tsx b/src/screens/MapView/FiltersAndSearch/FiltersAndSearch.tsx new file mode 100644 index 00000000..a9f4d43d --- /dev/null +++ b/src/screens/MapView/FiltersAndSearch/FiltersAndSearch.tsx @@ -0,0 +1,107 @@ +import TextInput from '@rn-ui-lib/components/TextInput'; +import SearchIcon from '@rn-ui-lib/icons/SearchIcon'; +import React, { useRef, useEffect, useState } from 'react'; +import { Animated, StyleSheet, View } from 'react-native'; +import { IFiltersAndSearchProps, SearchState } from './interfaces'; +import { COLORS } from '@rn-ui-lib/colors'; +import { useAppDispatch, useAppSelector } from '@hooks'; +import { getSearchQuery } from '@screens/Dashboard/utils'; +import { setAllCasesViewSearchQuery } from '@reducers/allCasesSlice'; +import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; +import { addClickstreamEvent } from '@services/clickstreamEventService'; + +const FiltersAndSearch = (props: IFiltersAndSearchProps) => { + const { setSearchState, showAutoFocus } = props; + + const slideAnimation = useRef(new Animated.Value(-40)).current; + + const existingSearchQuery = useAppSelector((state) => getSearchQuery(state, false, false)); + const showAnimation = !existingSearchQuery; + + const [searchQuery, setSearchQuery] = useState(existingSearchQuery || ''); + + const dispatch = useAppDispatch(); + + const handleSearchChange = (query: string) => { + setSearchQuery(query); + }; + + const handleSearchOnEnter = (query: string) => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_SEARCHED, { query }); + dispatch(setAllCasesViewSearchQuery(query)); + }; + + const hideSearchBar = () => { + if (searchQuery?.length > 0) { + handleSearchChange(''); + handleSearchOnEnter(''); + } + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_SEARCH_CLOSE_BUTTON_CLICKED); + setSearchState(SearchState.HIDDEN); + }; + + useEffect(() => { + if (showAnimation) { + Animated.timing(slideAnimation, { + toValue: 0, + duration: 300, + useNativeDriver: false, + }).start(); + } + }, [showAnimation]); + + return ( + + + } + onChangeText={handleSearchChange} + onSubmitEditing={() => handleSearchOnEnter(searchQuery)} + placeholder={`Search in my cases`} + defaultValue={searchQuery} + value={searchQuery} + testID="test_search" + showClearIcon + closeIconHandler={hideSearchBar} + showCrossIconOnEmptySearch + onFocus={() => setSearchState(SearchState.FOCUSED)} + onBlur={() => setSearchState(SearchState.VISIBLE)} + autoFocus={showAutoFocus} + /> + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: COLORS.BACKGROUND.INDIGO, + paddingBottom: 12, + paddingHorizontal: 16, + }, + textInput: { + flex: 1, + }, + searchContainer: { + position: 'absolute', + width: '100%', + top: 54, + left: 0, + right: 0, + }, +}); +export default FiltersAndSearch; diff --git a/src/screens/MapView/FiltersAndSearch/OverlayScreen.tsx b/src/screens/MapView/FiltersAndSearch/OverlayScreen.tsx new file mode 100644 index 00000000..9bd7f84a --- /dev/null +++ b/src/screens/MapView/FiltersAndSearch/OverlayScreen.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { IOverlayScreenProps } from './interfaces'; +import { Keyboard, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; + +const OverlayScreen = (props: IOverlayScreenProps) => { + const { children, overlayBackground, onDismiss } = props; + const handlePress = () => { + Keyboard.dismiss(); //To dismiss keyboard + if (onDismiss) onDismiss(); + }; + return ( + + {children} + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + opacity: 0.7, + backgroundColor: 'black', + }, +}); + +export default OverlayScreen; diff --git a/src/screens/MapView/FiltersAndSearch/RightActionableContainer.tsx b/src/screens/MapView/FiltersAndSearch/RightActionableContainer.tsx new file mode 100644 index 00000000..5ffb8d16 --- /dev/null +++ b/src/screens/MapView/FiltersAndSearch/RightActionableContainer.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import FilterIcon from '@assets/icons/FilterIcon'; +import Text from '@rn-ui-lib/components/Text'; +import { COLORS } from '@rn-ui-lib/colors'; +import { View, TouchableHighlight, StyleSheet } from 'react-native'; +import SearchIcon from '@assets/icons/SearchIcon'; +import { IRightActionableContainerProps } from './interfaces'; +import { useAppSelector } from '@hooks'; + +const RightActionableContainer = (props: IRightActionableContainerProps) => { + const { showSearch, toggleSearch, toggleFilterModal } = props; + const filterCount = useAppSelector((state) => state?.filters?.filterCount); + + return ( + + {showSearch ? ( + + + + ) : null} + + + + {filterCount > 0 && ( + + 9 ? styles.ph6 : styles.ph8, + ]} + > + {filterCount} + + + )} + + + + ); +}; +const styles = StyleSheet.create({ + iconContainerButton: { + height: 40, + width: 40, + borderRadius: 24, + justifyContent: 'center', + alignItems: 'center', + }, + filterCountText: { + fontSize: 9, + fontWeight: '400', + height: 22, + }, + filterCount: { + backgroundColor: COLORS.TEXT.BLUE, + paddingVertical: 0, + borderRadius: 12, + position: 'absolute', + opacity: 0.8, + top: 12, + right: -6, + }, + filterIcon: { + backgroundColor: COLORS.BACKGROUND.PRIMARY, + }, + ph6: { + paddingHorizontal: 6, + }, + ph8: { + paddingHorizontal: 8, + }, +}); +export default RightActionableContainer; diff --git a/src/screens/MapView/FiltersAndSearch/interfaces.ts b/src/screens/MapView/FiltersAndSearch/interfaces.ts new file mode 100644 index 00000000..4d98307a --- /dev/null +++ b/src/screens/MapView/FiltersAndSearch/interfaces.ts @@ -0,0 +1,30 @@ +import { StyleProp, ViewStyle } from 'react-native'; + +export enum SearchState { + HIDDEN, // Search is not visible + VISIBLE, // Search is visible but not focused + FOCUSED, // Search is visible and focused +} + +export interface IFiltersAndSearchProps { + setSearchState: React.Dispatch>; + showAutoFocus: boolean; +} + +export interface IOverlayScreenProps { + children: React.ReactNode; + overlayBackground?: StyleProp; + onDismiss?: () => void; +} + +export interface IRightActionableContainerProps { + showSearch: boolean; + toggleSearch: () => void; + toggleFilterModal: () => void; +} + +export interface IBackdropContainerProps { + isSearchFocused: boolean; + showEmptyListComponent: boolean; + isFilterApplied: boolean; +} diff --git a/src/screens/MapView/LoadingScreen/index.tsx b/src/screens/MapView/LoadingScreen/index.tsx new file mode 100644 index 00000000..8a93b558 --- /dev/null +++ b/src/screens/MapView/LoadingScreen/index.tsx @@ -0,0 +1,47 @@ +import { isFunction } from '@components/utlis/commonFunctions'; +import { COLORS } from '@rn-ui-lib/colors'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import React, { useEffect } from 'react'; +import { ActivityIndicator, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; +import { OVERLAY_SCREEN_Z_INDEX } from '../constants'; + +interface ILoadingScreen { + handleMapReady: () => void; +} + +const LoadingScreen = (props: ILoadingScreen) => { + const { handleMapReady } = props; + + useEffect(() => { + return () => { + if (isFunction(handleMapReady)) { + handleMapReady(); + } + }; + }, []); + + return ( + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + top: 0, + width: '100%', + height: '100%', + flex: 1, + position: 'absolute', + zIndex: OVERLAY_SCREEN_Z_INDEX, + opacity: 0.7, + backgroundColor: 'black', + }, +}); + +export default LoadingScreen; diff --git a/src/screens/MapView/MapContext.tsx b/src/screens/MapView/MapContext.tsx new file mode 100644 index 00000000..b21ad2a0 --- /dev/null +++ b/src/screens/MapView/MapContext.tsx @@ -0,0 +1,40 @@ +import React, { createContext, useContext, useState } from 'react'; +import { IconStates } from './utils'; +import { ILocationData } from '@screens/addresses/interfaces'; + +interface MapContextType { + iconState: IconStates; + setIconState: (state: IconStates) => void; + selectedPin: string | null; + setSelectedPin: (pin: string | null) => void; +} + +const MapContext = createContext(undefined); + +export const MapProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [iconState, setIconState] = useState(IconStates.DEFAULT); + const [selectedPin, setSelectedPin] = useState(null); + + return ( + + {children} + + ); +}; + +export const useMapContext = () => { + const context = useContext(MapContext); + if (!context) { + throw new Error('useMapContext must be used within MapProvider'); + } + return context; +}; + +export default MapContext; diff --git a/src/screens/MapView/MapViewStack.tsx b/src/screens/MapView/MapViewStack.tsx new file mode 100644 index 00000000..fcbd9ae1 --- /dev/null +++ b/src/screens/MapView/MapViewStack.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { getScreenFocusListenerObj } from '@components/utlis/commonFunctions'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { DEFAULT_SCREEN_OPTIONS } from '@screens/auth/ProtectedRouter'; +import MapView from '.'; + +const Stack = createNativeStackNavigator(); + +export enum MapViewStackEnum { + MAP_VIEW_MAIN = 'mapViewMain', +} + +const MapViewStack = () => { + return ( + + + + ); +}; + +export default MapViewStack; diff --git a/src/screens/MapView/ProfileHeader/CurvedBackground.tsx b/src/screens/MapView/ProfileHeader/CurvedBackground.tsx new file mode 100644 index 00000000..7db6f05f --- /dev/null +++ b/src/screens/MapView/ProfileHeader/CurvedBackground.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { View } from 'react-native'; +import Svg, { G, Path } from 'react-native-svg'; + +const CurvedBackground = () => ( + + + + + + + +); + +export default CurvedBackground; diff --git a/src/screens/MapView/ProfileHeader/ProfileAvatar.tsx b/src/screens/MapView/ProfileHeader/ProfileAvatar.tsx new file mode 100644 index 00000000..9876adcb --- /dev/null +++ b/src/screens/MapView/ProfileHeader/ProfileAvatar.tsx @@ -0,0 +1,72 @@ +import Text from '@rn-ui-lib/components/Text'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import FastImage from 'react-native-fast-image'; +import CurvedBackground from './CurvedBackground'; +import { COLORS } from '@rn-ui-lib/colors'; +import RNFS from 'react-native-fs'; +import { GlobalImageMap } from '@common/CachedImage'; + +interface IProfileAvatar { + name: string; + caseId: string; +} + +const ProfileAvatar: React.FC = ({ name, caseId }) => { + const cacheDirectory = RNFS.CachesDirectoryPath; + const cacheFilePath = `${cacheDirectory}/${caseId}.jpg`; + + const renderProfileImage = () => { + if (GlobalImageMap[cacheFilePath]) { + return ( + + ); + } + + return ( + + {name.slice(0, 2).toUpperCase()} + + ); + }; + + return ( + + + {renderProfileImage()} + + ); +}; + +const styles = StyleSheet.create({ + profileImageContainer: { + position: 'absolute', + top: -41, + }, + profileImage: { + left: 19, + bottom: 5, + width: 46, + height: 46, + borderRadius: 16, + }, + placeholderImage: { + left: 19, + bottom: 5, + width: 46, + height: 46, + borderRadius: 16, + backgroundColor: COLORS.BACKGROUND.SILVER_LIGHT, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: COLORS.BACKGROUND.PRIMARY, + }, + placeholderText: { + fontSize: 16, + fontWeight: '600', + color: COLORS.TEXT.BLACK, + }, +}); + +export default ProfileAvatar; diff --git a/src/screens/MapView/ProfileHeader/index.tsx b/src/screens/MapView/ProfileHeader/index.tsx new file mode 100644 index 00000000..cd301cd4 --- /dev/null +++ b/src/screens/MapView/ProfileHeader/index.tsx @@ -0,0 +1,119 @@ +import React, { useMemo } from 'react'; +import { View, StyleSheet, Linking } from 'react-native'; +import Text from '@rn-ui-lib/components/Text'; +import Tag, { TagVariant } from '@rn-ui-lib/components/Tag'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import ProfileAvatar from './ProfileAvatar'; +import { useAppSelector } from '@hooks'; +import { paymentStatusMapping } from '@screens/allCases/utils'; +import { formatAmount } from '@rn-ui-lib/utils/amount'; +import { getDistanceFromLatLonInKm, getGoogleMapUrl } from '@components/utlis/commonFunctions'; +import relativeDistanceFormatter from '@screens/addressGeolocation/utils/relativeDistanceFormatter'; +import { LocationType } from '@screens/addresses/interfaces'; +import Button from '@rn-ui-lib/components/Button'; +import MapDirectionIcon from '@assets/icons/MapDirectionIcon'; +import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; +import { addClickstreamEvent } from '@services/clickstreamEventService'; + +interface ProfileHeaderProps { + caseId: string; + showDirections?: boolean; +} + +const ProfileHeader: React.FC = ({ caseId, showDirections = false }) => { + const caseDetails = useAppSelector((state) => state.allCases?.caseDetails?.[caseId]); + const { customerName, totalOverdueAmount, paymentStatus, collectionTag, dpdBucket } = + caseDetails || {}; + const deviceGeolocationCoordinate = useAppSelector( + (state) => state.foregroundService.deviceGeolocationCoordinate + ); + const isGeoLocation = caseDetails?.addressStringType === LocationType.GEO_LOCATION; + + const relativeDistanceBwLatLong = useMemo(() => { + const distance = getDistanceFromLatLonInKm(deviceGeolocationCoordinate, { + latitude: caseDetails?.addressLocation?.latitude, + longitude: caseDetails?.addressLocation?.longitude, + }); + return `${relativeDistanceFormatter(distance)} km`; + }, [deviceGeolocationCoordinate]); + + const handleDirectionsPress = () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_MAP_CASE_CARD_MAP_DIRECTION_CLICKED, { + markerId: caseId, + }); + const mapUrl = getGoogleMapUrl( + caseDetails?.addressLocation?.latitude, + caseDetails?.addressLocation?.longitude + ); + + if (mapUrl) { + return Linking.openURL(mapUrl); + } + }; + + return ( + + + + + + {customerName} + + {showDirections && + (!isGeoLocation ? ( + + {relativeDistanceBwLatLong} + + ) : ( +