From 58af541536247283d9d83c0419d20550e3ffcde9 Mon Sep 17 00:00:00 2001 From: Abin Toppo Date: Tue, 2 Jun 2026 16:36:27 +0530 Subject: [PATCH] Add global search, in-app + push notifications, and fix card/beneficiary/scroll issues - Global feature search for the user app, wired to the home search icon - Notifications screen: swipe-to-delete, vanish animation, unread badge, and Notifee-backed foreground + background (scheduled) OS notifications - Fix Add Beneficiary save (single source of truth via beneficiaries slice) - Fix Cards tab scroll freeze and bottom blank space (use ScreenShell scroll) - User-friendly toast copy across the app - Android build tuning: parallel/caching/JVM G1 args, mobile-only ABIs Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 +- .vscode/settings.json | 3 +- android/app/src/main/AndroidManifest.xml | 2 + android/gradle.properties | 47 +- index.js | 8 + package-lock.json | 10 + package.json | 1 + src/app/rootReducer.ts | 2 + src/constants/mockData.ts | 52 ++ src/hooks/useAppBootstrap.ts | 2 +- src/modules/auth/components/LoginRoleTabs.tsx | 10 +- src/modules/auth/screens/LoginScreen.tsx | 12 +- .../cards/screens/ApplyCreditCardScreen.tsx | 12 +- .../cards/screens/CardDetailsScreen.tsx | 36 +- .../cards/screens/CardsOverviewScreen.tsx | 53 +- .../screens/RequestPhysicalCardScreen.tsx | 6 +- .../deposit/screens/DepositDetailsScreen.tsx | 2 +- .../screens/OpenFixedDepositScreen.tsx | 4 +- .../screens/OpenRecurringDepositScreen.tsx | 6 +- src/modules/home/screens/HomeScreen.tsx | 36 +- .../insurance/screens/BuyInsuranceScreen.tsx | 2 +- .../screens/InsuranceClaimsScreen.tsx | 4 +- .../screens/InsuranceDocumentsScreen.tsx | 6 +- .../screens/InsurancePolicyDetailsScreen.tsx | 2 +- .../screens/InsuranceRenewalsScreen.tsx | 6 +- .../screens/NotificationsScreen.tsx | 389 ++++++++++ .../notifications/store/notificationsSlice.ts | 52 ++ .../payments/screens/AddBeneficiaryScreen.tsx | 57 +- .../screens/BeneficiariesListScreen.tsx | 5 +- .../screens/PaymentConfirmationScreen.tsx | 8 +- .../payments/screens/PaymentFormScreen.tsx | 5 +- .../payments/screens/PaymentsHomeScreen.tsx | 4 +- .../payments/screens/TransferTypeScreen.tsx | 6 +- .../payments/store/beneficiariesSlice.ts | 7 + .../search/screens/GlobalSearchScreen.tsx | 207 ++++++ src/modules/search/searchCatalog.ts | 675 ++++++++++++++++++ .../screens/CertificateListScreen.tsx | 2 +- .../screens/ChequeBookRequestScreen.tsx | 2 +- .../services/screens/Form15G15HScreen.tsx | 6 +- .../services/screens/PositivePayScreen.tsx | 4 +- .../services/screens/ServicesHubScreen.tsx | 6 +- .../screens/SpecialInstructionsScreen.tsx | 4 +- .../services/screens/StopPaymentScreen.tsx | 2 +- src/navigation/HomeNavigator.tsx | 4 + src/navigation/RootNavigator.tsx | 2 - src/navigation/types.ts | 2 + .../notifications/pushNotifications.ts | 133 +++- src/types/app.ts | 19 +- 48 files changed, 1773 insertions(+), 157 deletions(-) create mode 100644 src/modules/notifications/screens/NotificationsScreen.tsx create mode 100644 src/modules/notifications/store/notificationsSlice.ts create mode 100644 src/modules/search/screens/GlobalSearchScreen.tsx create mode 100644 src/modules/search/searchCatalog.ts diff --git a/.gitignore b/.gitignore index 977217c..0b33ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ coverage .cxx/ android/app/.cxx/ **/ComponentDescriptors.cpp.o -**/react_codegen_rngesturehandler_codegen.dir/ \ No newline at end of file +**/react_codegen_rngesturehandler_codegen.dir/ + +# Local Claude Code session data (never push) +.claude/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b016a8..d53ecaf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "java.compile.nullAnalysis.mode": "automatic" + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic" } \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1a78b88..3bc75ee 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + -PreactNativeArchitectures=x86_64 -reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 +# Mobile-only: build for physical Android devices (ARM) and skip emulator ABIs (x86, x86_64). +# Dropping the two emulator architectures roughly halves native build time and APK size. +# Note: x86_64 Android Studio emulators (AVDs) will NOT install this build. To run on an +# emulator, override per build from the CLI: +# ./gradlew -PreactNativeArchitectures=arm64-v8a,armeabi-v7a,x86,x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a # Use this property to enable support to the new architecture. # This will allow you to use TurboModules and the Fabric render in @@ -42,3 +71,5 @@ hermesEnabled=true # This allows your app to draw behind system bars for an immersive UI. # Note: Only works with ReactActivity and should not be used with custom Activity. edgeToEdgeEnabled=false + + diff --git a/index.js b/index.js index 858e500..debaf9b 100644 --- a/index.js +++ b/index.js @@ -3,9 +3,17 @@ */ import { AppRegistry, Text, TextInput } from 'react-native'; +import notifee from '@notifee/react-native'; import App from './App'; import { name as appName } from './app.json'; +// Required by Notifee: handles notification interactions that happen while the +// app is in the background or quit. Tapping a notification opens the app via +// the default press action, so no extra work is needed here. +notifee.onBackgroundEvent(async () => { + // No-op: the default press action launches the app. +}); + const customTextProps = { style: { fontFamily: 'Nunito-Regular', diff --git a/package-lock.json b/package-lock.json index 4038e57..7f41b65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@gorhom/bottom-sheet": "^5.2.14", "@maplibre/maplibre-react-native": "^10.4.2", + "@notifee/react-native": "^9.1.8", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/geolocation": "^3.4.0", "@react-native-vector-icons/ionicons": "^13.0.0", @@ -2829,6 +2830,15 @@ "node": ">= 8" } }, + "node_modules/@notifee/react-native": { + "version": "9.1.8", + "resolved": "https://registry.npmjs.org/@notifee/react-native/-/react-native-9.1.8.tgz", + "integrity": "sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/@react-native-async-storage/async-storage": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", diff --git a/package.json b/package.json index 5ccefb7..74ee052 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@gorhom/bottom-sheet": "^5.2.14", "@maplibre/maplibre-react-native": "^10.4.2", + "@notifee/react-native": "^9.1.8", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/geolocation": "^3.4.0", "@react-native-vector-icons/ionicons": "^13.0.0", diff --git a/src/app/rootReducer.ts b/src/app/rootReducer.ts index d65a652..3b6e371 100644 --- a/src/app/rootReducer.ts +++ b/src/app/rootReducer.ts @@ -16,6 +16,7 @@ import { agentReducer } from '../modules/agent/store/agentSlice'; import { schemesReducer } from '../modules/schemes/store/schemesSlice'; import { schemesApi } from '../modules/schemes/services/schemesApi'; import { beneficiariesReducer } from '../modules/payments/store/beneficiariesSlice'; +import { notificationsReducer } from '../modules/notifications/store/notificationsSlice'; const appReducer = combineReducers({ auth: authReducer, @@ -31,6 +32,7 @@ const appReducer = combineReducers({ ui: uiReducer, schemes: schemesReducer, beneficiaries: beneficiariesReducer, + notifications: notificationsReducer, agent: agentReducer, [baseApi.reducerPath]: baseApi.reducer, [schemesApi.reducerPath]: schemesApi.reducer, diff --git a/src/constants/mockData.ts b/src/constants/mockData.ts index af26a67..4eb9070 100644 --- a/src/constants/mockData.ts +++ b/src/constants/mockData.ts @@ -1,6 +1,7 @@ import type { AccountSummary, ActiveLoan, + AppNotification, BankCard, Beneficiary, Branch, @@ -41,6 +42,57 @@ export const demoUser: UserProfile = { }, }; +export const demoNotifications: AppNotification[] = [ + { + id: 'ntf_login', + category: 'security', + title: 'New sign-in detected', + body: 'We noticed a new sign-in on a Pixel device in Bengaluru. If this was you, no action is needed.', + createdAt: '2026-06-02T08:45:00+05:30', + read: false, + }, + { + id: 'ntf_credit', + category: 'transaction', + title: 'Money received', + body: '₹24,500 was credited to your Savings account XXXX 9312 from Priya Nair.', + createdAt: '2026-06-02T07:10:00+05:30', + read: false, + }, + { + id: 'ntf_card_bill', + category: 'reminder', + title: 'Credit card bill due soon', + body: 'Your card payment of ₹12,870 is due on 8 June. Pay early to avoid interest charges.', + createdAt: '2026-06-01T18:30:00+05:30', + read: false, + }, + { + id: 'ntf_offer', + category: 'offer', + title: 'Festive cashback unlocked', + body: 'Get 5% cashback on UPI payments above ₹500 this week. Terms apply.', + createdAt: '2026-05-31T11:00:00+05:30', + read: true, + }, + { + id: 'ntf_fd', + category: 'reminder', + title: 'Fixed deposit maturing', + body: 'Your fixed deposit of ₹1,00,000 matures on 15 June. Choose to renew or withdraw.', + createdAt: '2026-05-30T09:20:00+05:30', + read: true, + }, + { + id: 'ntf_kyc', + category: 'system', + title: 'KYC verification complete', + body: 'Your KYC details have been verified successfully. All account features are now active.', + createdAt: '2026-05-28T16:05:00+05:30', + read: true, + }, +]; + export const demoAccounts: AccountSummary[] = [ { id: 'acc_primary', diff --git a/src/hooks/useAppBootstrap.ts b/src/hooks/useAppBootstrap.ts index 7e677be..269a6fa 100644 --- a/src/hooks/useAppBootstrap.ts +++ b/src/hooks/useAppBootstrap.ts @@ -65,7 +65,7 @@ export const useAppBootstrap = () => { useEffect(() => { const unregister = registerUnauthorizedHandler(() => { dispatch(logoutSession()); - toast('Signed out', 'info', 'Your session was refreshed for security reasons.'); + toast('Signed out', 'info', 'For your security, your session was refreshed.'); }); return unregister; diff --git a/src/modules/auth/components/LoginRoleTabs.tsx b/src/modules/auth/components/LoginRoleTabs.tsx index c45d44e..69dae47 100644 --- a/src/modules/auth/components/LoginRoleTabs.tsx +++ b/src/modules/auth/components/LoginRoleTabs.tsx @@ -11,11 +11,11 @@ interface LoginRoleTabsProps { onRoleChange: (role: LoginRole) => void; } -const ROLES: LoginRole[] = ['User', 'Employee', 'Agent']; +const ROLES: LoginRole[] = ['User', 'Agent']; const ActiveTabBackground = () => ( - = ({ activeRole, onRole {ROLES.map((role, index) => { const isActive = activeRole === role; - + return ( = ({ activeRole, onRole ]} > {isActive && } - + ) { const handleForgotPassword = () => { haptics.selection(); - toast('Redirecting to secure reset channel...', 'info'); + toast('Resetting your password', 'info', 'We are taking you to reset your password.'); }; const handleSignUp = () => { haptics.selection(); - toast('Initializing onboarding sequence...', 'info'); + toast('Creating your account', 'info', 'We are taking you to open a new account.'); }; const handleContinue = async () => { @@ -55,11 +55,11 @@ export default function LoginScreen(props: Readonly) { await secureStorage.saveSession(tokens); dispatch(authActions.authenticate(tokens)); haptics.success(); - toast('Neural bridge established', 'success'); + toast('Signed in', 'success', 'Welcome back to your account.'); return; } catch { haptics.error(); - toast('Access Denied', 'error', 'Authorization failed. Retry handshake.'); + toast('Sign-in failed', 'error', 'We could not verify your details. Please check and try again.'); } }; @@ -68,7 +68,7 @@ export default function LoginScreen(props: Readonly) { const success = await promptBiometric(); if (!success) { - toast('Biometric scan aborted', 'info'); + toast('Biometric sign-in cancelled', 'info', 'You can try again or sign in with your password.'); return; } @@ -82,7 +82,7 @@ export default function LoginScreen(props: Readonly) { await secureStorage.saveSession(tokens); dispatch(authActions.authenticate(tokens)); haptics.success(); - toast('Synapse link verified', 'success'); + toast('Signed in', 'success', 'Your identity was confirmed. Welcome back.'); }; return ( diff --git a/src/modules/cards/screens/ApplyCreditCardScreen.tsx b/src/modules/cards/screens/ApplyCreditCardScreen.tsx index fc0d1cb..2d2f5d7 100644 --- a/src/modules/cards/screens/ApplyCreditCardScreen.tsx +++ b/src/modules/cards/screens/ApplyCreditCardScreen.tsx @@ -91,31 +91,31 @@ export default function ApplyCreditCardScreen({ navigation }: Props) { toast( 'Income too low', 'error', - 'Enter an annual income of at least Rs 3,00,000 to continue.', + 'Please enter an annual income of at least Rs 3,00,000 to continue.', ); return; } if (!employerName.trim()) { - toast('Employer required', 'error', 'Enter your employer or business name.'); + toast('Employer name needed', 'error', 'Please enter your employer or business name.'); return; } if (!city.trim()) { - toast('City required', 'error', 'Enter your current city to continue.'); + toast('City needed', 'error', 'Please enter your current city to continue.'); return; } if (!selectedRepaymentAccount) { - toast('Account required', 'error', 'Select a repayment account for your credit card.'); + toast('Repayment account needed', 'error', 'Please select a repayment account for your credit card.'); return; } if (!consentAccepted) { toast( - 'Consent required', + 'Consent needed', 'error', - 'Accept the bureau and KYC consent to submit your application.', + 'Please accept the credit bureau and KYC consent to submit your application.', ); return; } diff --git a/src/modules/cards/screens/CardDetailsScreen.tsx b/src/modules/cards/screens/CardDetailsScreen.tsx index 776980f..509ec09 100644 --- a/src/modules/cards/screens/CardDetailsScreen.tsx +++ b/src/modules/cards/screens/CardDetailsScreen.tsx @@ -200,8 +200,8 @@ export default function CardDetailsScreen({ route }: Props) { nextStatus === 'blocked' ? 'Card blocked' : 'Card active', nextStatus === 'blocked' ? 'error' : 'success', nextStatus === 'blocked' - ? 'All new transactions are disabled.' - : 'Transactions are enabled again.', + ? 'New transactions on this card are now stopped.' + : 'You can use this card again.', ); }; @@ -217,8 +217,8 @@ export default function CardDetailsScreen({ route }: Props) { card.controls.frozen ? 'Card unfrozen' : 'Card frozen', 'success', card.controls.frozen - ? 'The card is ready for use again.' - : 'Temporary freeze applied successfully.', + ? 'Your card is ready to use again.' + : 'Transactions on this card are paused for now.', ); }; @@ -227,16 +227,16 @@ export default function CardDetailsScreen({ route }: Props) { if (!authenticated) { toast( - 'Authentication required', + 'Verification needed', 'info', - 'Use biometrics to reveal the full number and CVV.', + 'Confirm with your fingerprint or face to view the full number and CVV.', ); return; } setSensitiveVisible((currentValue) => !currentValue); toast( - sensitiveVisible ? 'Sensitive details hidden' : 'Sensitive details unlocked', + sensitiveVisible ? 'Card details hidden' : 'Card details shown', 'success', ); }; @@ -245,11 +245,11 @@ export default function CardDetailsScreen({ route }: Props) { const authenticated = await promptBiometric(); if (!authenticated) { - toast(actionLabel, 'info', 'Biometric confirmation is required for PIN actions.'); + toast(actionLabel, 'info', 'Please confirm with your fingerprint or face to manage your PIN.'); return; } - toast(actionLabel, 'success', `${actionLabel} journey started securely.`); + toast(actionLabel, 'success', `You can now ${actionLabel.toLowerCase()} securely.`); }; const handleGenerateVirtualCard = () => { @@ -259,18 +259,18 @@ export default function CardDetailsScreen({ route }: Props) { virtualCard: createVirtualCardPayload(card.id, virtualLimitDraft), }), ); - toast('Virtual card created', 'success', 'Your online-use virtual card is ready.'); + toast('Virtual card created', 'success', 'Your virtual card for online payments is ready.'); }; const handleDeleteVirtualCard = () => { dispatch(cardsActions.deleteVirtualCard({ cardId: card.id })); setVirtualLimitDraft(10000); - toast('Virtual card deleted', 'info', 'The virtual card was removed successfully.'); + toast('Virtual card deleted', 'info', 'Your virtual card has been removed.'); }; const handleRedeemRewards = () => { if (card.rewards.points <= 0) { - toast('No reward points', 'info', 'There are no points available to redeem.'); + toast('No reward points', 'info', 'You do not have any points to redeem right now.'); return; } @@ -279,7 +279,7 @@ export default function CardDetailsScreen({ route }: Props) { toast( 'Rewards redeemed', 'success', - `${pointsToRedeem.toLocaleString('en-IN')} points were redeemed to your reward balance.`, + `${pointsToRedeem.toLocaleString('en-IN')} points have been added to your reward balance.`, ); }; @@ -287,25 +287,25 @@ export default function CardDetailsScreen({ route }: Props) { const nextName = renameDraft.trim(); if (nextName.length < 3) { - toast('Invalid card name', 'error', 'Use at least 3 characters for the new card name.'); + toast('Card name too short', 'error', 'Please use at least 3 characters for the card name.'); return; } dispatch(cardsActions.renameCard({ cardId: card.id, name: nextName })); - toast('Card renamed', 'success', `This card is now labeled "${nextName}".`); + toast('Card renamed', 'success', `Your card is now named "${nextName}".`); }; const handleSetDefaultCard = () => { dispatch(cardsActions.setDefaultCard({ cardId: card.id })); - toast('Default card updated', 'success', 'This card is now your default payment card.'); + toast('Default card updated', 'success', 'This card is now your default for payments.'); }; const handleStatementDownload = (statement: CardStatement) => { - toast('Statement download', 'success', `${statement.monthLabel} PDF is ready for download.`); + toast('Download started', 'success', `Your ${statement.monthLabel} statement PDF is ready to download.`); }; const handleStatementEmail = (statement: CardStatement) => { - toast('Statement email queued', 'info', `${statement.monthLabel} will be emailed shortly.`); + toast('Statement on its way', 'info', `Your ${statement.monthLabel} statement will arrive in your email shortly.`); }; const budgetProgress = diff --git a/src/modules/cards/screens/CardsOverviewScreen.tsx b/src/modules/cards/screens/CardsOverviewScreen.tsx index 8014369..c57d732 100644 --- a/src/modules/cards/screens/CardsOverviewScreen.tsx +++ b/src/modules/cards/screens/CardsOverviewScreen.tsx @@ -1,7 +1,6 @@ import React, { useMemo } from 'react'; import { Pressable, - ScrollView, StyleSheet, Text, View, @@ -16,6 +15,7 @@ import { useToast } from '../../../hooks/useToast'; import type { CardsStackParamList } from '../../../navigation/types'; import { useTheme } from '../../../theme'; import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters'; +import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants'; import { CardCarousel } from '../components/CardCarousel'; import { CardDeliveryStatus } from '../components/CardDeliveryStatus'; import { CardsSkeleton } from '../components/CardsSkeleton'; @@ -260,8 +260,8 @@ export default function CardsOverviewScreen({ navigation }: Props) { nextStatus === 'blocked' ? 'Card blocked' : 'Card unblocked', nextStatus === 'blocked' ? 'error' : 'success', nextStatus === 'blocked' - ? 'This card is now blocked for all new transactions.' - : 'This card is active again.', + ? 'Your card is now blocked for all new transactions.' + : 'Your card is active again.', ); }; @@ -296,14 +296,18 @@ export default function CardsOverviewScreen({ navigation }: Props) { - - - + + Request Card + + } + > + Selected wallet card @@ -558,32 +562,15 @@ export default function CardsOverviewScreen({ navigation }: Props) { - - - - - - Request Card - - - ); } const styles = StyleSheet.create({ - flex: { - flex: 1, - }, scrollContent: { gap: 18, - paddingBottom: 120, + // Clear the absolute tab bar + the floating "Request Card" button. + paddingBottom: TAB_BAR_HEIGHT + 120, }, emptyStateWrap: { gap: 20, @@ -805,13 +792,10 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: '800', }, - footerSpace: { - height: 24, - }, fab: { position: 'absolute', right: 4, - bottom: 22, + bottom: TAB_BAR_HEIGHT + 20, flexDirection: 'row', alignItems: 'center', gap: 8, @@ -829,7 +813,4 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '800', }, - bottomInset: { - height: 60, - }, }); diff --git a/src/modules/cards/screens/RequestPhysicalCardScreen.tsx b/src/modules/cards/screens/RequestPhysicalCardScreen.tsx index 61d836e..87cff54 100644 --- a/src/modules/cards/screens/RequestPhysicalCardScreen.tsx +++ b/src/modules/cards/screens/RequestPhysicalCardScreen.tsx @@ -96,17 +96,17 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) { const handleSubmit = () => { if (!selectedAccount) { - toast('Unable to place request', 'error', 'Select a linked account first.'); + toast('We need an account first', 'error', 'Please choose the account you want this card linked to.'); return; } if (!nameOnCard.trim()) { - toast('Name required', 'error', 'Enter the cardholder name to print on the card.'); + toast('Name needed', 'error', 'Please enter the name you would like printed on your card.'); return; } if (deliveryMode === 'home' && !deliveryAddress.trim()) { - toast('Address required', 'error', 'Add a delivery address for doorstep dispatch.'); + toast('Address needed', 'error', 'Please add the address where you would like your card delivered.'); return; } diff --git a/src/modules/deposit/screens/DepositDetailsScreen.tsx b/src/modules/deposit/screens/DepositDetailsScreen.tsx index a718427..3b126a0 100644 --- a/src/modules/deposit/screens/DepositDetailsScreen.tsx +++ b/src/modules/deposit/screens/DepositDetailsScreen.tsx @@ -84,7 +84,7 @@ export default function DepositDetailsScreen({ navigation, route }: Props) { 'success', deposit.status === 'matured' ? 'The maturity amount has been credited to your linked account.' - : 'Closure request completed successfully.', + : 'Your deposit has been closed and the proceeds are on their way.', ); } catch { toast( diff --git a/src/modules/deposit/screens/OpenFixedDepositScreen.tsx b/src/modules/deposit/screens/OpenFixedDepositScreen.tsx index c607b1e..0185153 100644 --- a/src/modules/deposit/screens/OpenFixedDepositScreen.tsx +++ b/src/modules/deposit/screens/OpenFixedDepositScreen.tsx @@ -99,7 +99,7 @@ export default function OpenFixedDepositScreen({ navigation }: Props) { setErrors(validation); if (Object.keys(validation).length) { - toast('Check deposit inputs', 'error', 'Please correct the highlighted fields.'); + toast('Some details need a look', 'error', 'Please check the details below and try again.'); return; } @@ -110,7 +110,7 @@ export default function OpenFixedDepositScreen({ navigation }: Props) { interestPayoutType: payoutType, }).unwrap(); - toast('Fixed deposit created', 'success', 'Your new FD is now active.'); + toast('Fixed deposit created', 'success', 'Your new fixed deposit is now active.'); navigation.replace('DepositDetails', { depositId: deposit.id, kind: deposit.kind, diff --git a/src/modules/deposit/screens/OpenRecurringDepositScreen.tsx b/src/modules/deposit/screens/OpenRecurringDepositScreen.tsx index 3f8864d..e5a8aff 100644 --- a/src/modules/deposit/screens/OpenRecurringDepositScreen.tsx +++ b/src/modules/deposit/screens/OpenRecurringDepositScreen.tsx @@ -88,7 +88,7 @@ export default function OpenRecurringDepositScreen({ navigation }: Props) { setErrors(validation); if (Object.keys(validation).length) { - toast('Check recurring deposit inputs', 'error', 'Please correct the highlighted fields.'); + toast('Some details are missing', 'error', 'Please check the details below and try again.'); return; } @@ -98,13 +98,13 @@ export default function OpenRecurringDepositScreen({ navigation }: Props) { tenureMonths, }).unwrap(); - toast('Recurring deposit created', 'success', 'Your new RD is now active.'); + toast('Recurring deposit created', 'success', 'Your new recurring deposit is now active.'); navigation.replace('DepositDetails', { depositId: deposit.id, kind: deposit.kind, }); } catch { - toast('Unable to open recurring deposit', 'error', 'Please try again shortly.'); + toast('We couldn\'t open your recurring deposit', 'error', 'Something went wrong on our end. Please try again in a moment.'); } }; diff --git a/src/modules/home/screens/HomeScreen.tsx b/src/modules/home/screens/HomeScreen.tsx index bd5e392..b4903e1 100644 --- a/src/modules/home/screens/HomeScreen.tsx +++ b/src/modules/home/screens/HomeScreen.tsx @@ -13,6 +13,7 @@ import { HomeSectionHeader } from '../components/HomeSectionHeader'; import { ServiceGridItem } from '../components/ServiceGridItem'; import { PromoBanner } from '../components/PromoBanner'; import { HorizontalCategories } from '../components/HorizontalCategories'; +import { selectUnreadNotificationCount } from '../../notifications/store/notificationsSlice'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); const SPACING = 16; @@ -27,6 +28,7 @@ export default function HomeScreen({ navigation }: Props) { const theme = useTheme(); const accounts = useAppSelector((state) => state.account.accounts); const profile = useAppSelector((state) => state.user.profile); + const unreadNotifications = useAppSelector(selectUnreadNotificationCount); const [activeIndex, setActiveIndex] = useState(0); const scrollX = useRef(new Animated.Value(0)).current; const account = accounts[activeIndex] || accounts[0]; @@ -97,10 +99,23 @@ export default function HomeScreen({ navigation }: Props) { - + navigation.navigate('Notifications')} + > + {unreadNotifications > 0 ? ( + + + {unreadNotifications > 9 ? '9+' : unreadNotifications} + + + ) : null} - + navigation.navigate('GlobalSearch')} + > @@ -276,6 +291,23 @@ const styles = StyleSheet.create({ shadowRadius: 5, elevation: 2, }, + badge: { + position: 'absolute', + top: 4, + right: 4, + minWidth: 18, + height: 18, + borderRadius: 9, + borderWidth: 1.5, + paddingHorizontal: 4, + alignItems: 'center', + justifyContent: 'center', + }, + badgeText: { + color: '#FFFFFF', + fontSize: 10, + fontWeight: '800', + }, section: { paddingHorizontal: 6, marginBottom: 24, diff --git a/src/modules/insurance/screens/BuyInsuranceScreen.tsx b/src/modules/insurance/screens/BuyInsuranceScreen.tsx index b93209c..197b622 100644 --- a/src/modules/insurance/screens/BuyInsuranceScreen.tsx +++ b/src/modules/insurance/screens/BuyInsuranceScreen.tsx @@ -100,7 +100,7 @@ export default function BuyInsuranceScreen({ navigation, route }: Props) { return; } - toast('Unable to send OTP', 'error', 'Please try again.'); + toast('Unable to send OTP', 'error', 'We could not send your code right now. Please try again.'); }; const handleConfirmPurchase = async () => { diff --git a/src/modules/insurance/screens/InsuranceClaimsScreen.tsx b/src/modules/insurance/screens/InsuranceClaimsScreen.tsx index 084a0da..c3e7cf3 100644 --- a/src/modules/insurance/screens/InsuranceClaimsScreen.tsx +++ b/src/modules/insurance/screens/InsuranceClaimsScreen.tsx @@ -116,7 +116,7 @@ export default function InsuranceClaimsScreen({ route }: Props) { return; } - toast('Unable to attach document', 'error', 'Please try again.'); + toast('Couldn’t attach document', 'error', 'Something went wrong while uploading. Please try again.'); }; const handleRequestOtp = async () => { @@ -136,7 +136,7 @@ export default function InsuranceClaimsScreen({ route }: Props) { return; } - toast('Unable to send OTP', 'error', 'Please try again.'); + toast('Couldn’t send OTP', 'error', 'We couldn’t send your one-time password. Please try again.'); }; const handleSubmitClaim = async () => { diff --git a/src/modules/insurance/screens/InsuranceDocumentsScreen.tsx b/src/modules/insurance/screens/InsuranceDocumentsScreen.tsx index e395e49..ab3560c 100644 --- a/src/modules/insurance/screens/InsuranceDocumentsScreen.tsx +++ b/src/modules/insurance/screens/InsuranceDocumentsScreen.tsx @@ -60,9 +60,9 @@ export default function InsuranceDocumentsScreen(_props: Props) { const result = await uploadDocument(sample); if ('data' in result) { - toast('Document uploaded', 'success', `${sample.name} is now available in the secure vault.`); + toast('Document uploaded', 'success', `${sample.name} is now saved securely with your documents.`); } else { - toast('Upload failed', 'error', 'Please try again.'); + toast('Upload didn’t go through', 'error', 'Please check your connection and try uploading again.'); } }; @@ -118,7 +118,7 @@ export default function InsuranceDocumentsScreen(_props: Props) { - toast('Document available', 'info', `${item.name} is stored in encrypted vault storage.`) + toast('Document available', 'info', `${item.name} is saved securely with your documents.`) } onReupload={() => void uploadDocument({ diff --git a/src/modules/insurance/screens/InsurancePolicyDetailsScreen.tsx b/src/modules/insurance/screens/InsurancePolicyDetailsScreen.tsx index edf65df..82976c6 100644 --- a/src/modules/insurance/screens/InsurancePolicyDetailsScreen.tsx +++ b/src/modules/insurance/screens/InsurancePolicyDetailsScreen.tsx @@ -127,7 +127,7 @@ export default function InsurancePolicyDetailsScreen({ navigation, route }: Prop toast( 'Policy download ready', 'success', - `${data.documents[0]?.name ?? 'Policy schedule'} is ready from the secure vault.`, + `${data.documents[0]?.name ?? 'Policy schedule'} is ready to download.`, ) } /> diff --git a/src/modules/insurance/screens/InsuranceRenewalsScreen.tsx b/src/modules/insurance/screens/InsuranceRenewalsScreen.tsx index 24eeac7..782e9dd 100644 --- a/src/modules/insurance/screens/InsuranceRenewalsScreen.tsx +++ b/src/modules/insurance/screens/InsuranceRenewalsScreen.tsx @@ -65,10 +65,10 @@ export default function InsuranceRenewalsScreen({ route }: Props) { toast( enabled ? 'Auto-renew enabled' : 'Auto-renew disabled', 'success', - `${due.planName} has been updated.`, + `Your settings for ${due.planName} have been updated.`, ); } else { - toast('Unable to update auto-renew', 'error', 'Please try again.'); + toast('Couldn\'t update auto-renew', 'error', 'Something went wrong on our end. Please try again.'); } }; @@ -87,7 +87,7 @@ export default function InsuranceRenewalsScreen({ route }: Props) { return; } - toast('Unable to send OTP', 'error', 'Please try again.'); + toast('Couldn\'t send OTP', 'error', 'We couldn\'t send your code right now. Please try again.'); }; const handleConfirmPayment = async () => { diff --git a/src/modules/notifications/screens/NotificationsScreen.tsx b/src/modules/notifications/screens/NotificationsScreen.tsx new file mode 100644 index 0000000..5ff23cc --- /dev/null +++ b/src/modules/notifications/screens/NotificationsScreen.tsx @@ -0,0 +1,389 @@ +import React, { useCallback, useMemo } from 'react'; +import { Dimensions, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { Swipeable } from 'react-native-gesture-handler'; +import Animated, { Easing, LinearTransition, withTiming } from 'react-native-reanimated'; +import type { ExitAnimationsValues, LayoutAnimation } from 'react-native-reanimated'; +import Ionicons from '@react-native-vector-icons/ionicons'; + +import { Button, Card, EmptyState, ScreenShell } from '../../../components'; +import { useAppDispatch, useAppSelector } from '../../../app/hooks'; +import { useTheme } from '../../../theme'; +import { useToast } from '../../../hooks/useToast'; +import { formatDate } from '../../../utils/formatters'; +import type { AppNotification, NotificationCategory } from '../../../types/app'; +import type { HomeStackParamList } from '../../../navigation/types'; +import { + displayLocalNotification, + getDummyNotificationContent, + scheduleLocalNotification, +} from '../../../services/notifications/pushNotifications'; +import { + notificationsActions, + selectNotifications, + selectUnreadNotificationCount, +} from '../store/notificationsSlice'; + +type Props = NativeStackScreenProps; + +type IconName = React.ComponentProps['name']; + +const CATEGORY_ICON: Record = { + transaction: 'swap-horizontal-outline', + security: 'shield-checkmark-outline', + offer: 'pricetag-outline', + reminder: 'alarm-outline', + system: 'information-circle-outline', +}; + +const categoryColor = ( + category: NotificationCategory, + colors: ReturnType['colors'], +) => { + switch (category) { + case 'security': + return colors.warning; + case 'offer': + return colors.accent; + case 'reminder': + return colors.info; + case 'system': + return colors.textSecondary; + case 'transaction': + default: + return colors.primary; + } +}; + +const formatRelativeTime = (iso: string) => { + const then = new Date(iso).getTime(); + const diffMinutes = Math.round((Date.now() - then) / 60000); + + if (!Number.isFinite(diffMinutes)) return formatDate(iso); + if (diffMinutes < 1) return 'Just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.round(diffHours / 24); + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + + return formatDate(iso); +}; + +const SCREEN_WIDTH = Dimensions.get('window').width; + +// Custom exit: the tile flies off to the left while shrinking and fading, +// so a deleted notification "vanishes" instead of blinking out. +const vanishExit = (_values: ExitAnimationsValues): LayoutAnimation => { + 'worklet'; + return { + initialValues: { + opacity: 1, + transform: [{ translateX: 0 }, { scale: 1 }], + }, + animations: { + opacity: withTiming(0, { duration: 220, easing: Easing.in(Easing.ease) }), + transform: [ + { translateX: withTiming(-SCREEN_WIDTH, { duration: 320, easing: Easing.in(Easing.cubic) }) }, + { scale: withTiming(0.7, { duration: 320, easing: Easing.in(Easing.cubic) }) }, + ], + }, + }; +}; + +export default function NotificationsScreen(_props: Props) { + const theme = useTheme(); + const dispatch = useAppDispatch(); + const toast = useToast(); + const notifications = useAppSelector(selectNotifications); + const unreadCount = useAppSelector(selectUnreadNotificationCount); + + const sorted = useMemo( + () => + [...notifications].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ), + [notifications], + ); + + const subtitle = + unreadCount > 0 + ? `${unreadCount} unread ${unreadCount === 1 ? 'update' : 'updates'}` + : "You're all caught up"; + + const handlePress = useCallback( + (notification: AppNotification) => { + if (!notification.read) { + dispatch(notificationsActions.markAsRead(notification.id)); + } + }, + [dispatch], + ); + + const handleMarkAll = useCallback(() => { + dispatch(notificationsActions.markAllRead()); + }, [dispatch]); + + const handleDelete = useCallback( + (id: string) => { + dispatch(notificationsActions.removeNotification(id)); + }, + [dispatch], + ); + + // Foreground demo: pop a system heads-up notification now and add it to the list. + const handleSendNow = useCallback(async () => { + const content = getDummyNotificationContent(); + dispatch( + notificationsActions.addNotification({ + id: `ntf_${Date.now()}`, + category: content.category, + title: content.title, + body: content.body, + createdAt: new Date().toISOString(), + read: false, + }), + ); + try { + await displayLocalNotification(content); + } catch { + toast( + 'Added to your list', + 'info', + 'Rebuild the app to also get the system pop-up.', + ); + } + }, [dispatch, toast]); + + // Background demo: schedule a notification, then minimise the app to see it arrive. + const handleSchedule = useCallback(async () => { + const content = getDummyNotificationContent(); + try { + await scheduleLocalNotification(content, 5); + toast( + 'Notification scheduled', + 'info', + 'Minimise the app — it arrives in about 5 seconds.', + ); + } catch { + toast("Couldn't schedule", 'error', 'Rebuild the app to enable scheduled notifications.'); + } + }, [toast]); + + const renderItem = useCallback( + ({ item }: { item: AppNotification }) => { + const accent = categoryColor(item.category, theme.colors); + + const renderRightActions = () => ( + handleDelete(item.id)} + style={[styles.deleteAction, { backgroundColor: theme.colors.danger }]} + > + + Delete + + ); + + return ( + + + handlePress(item)}> + + + + + + + + {item.title} + + + {item.body} + + + {formatRelativeTime(item.createdAt)} + + + {!item.read ? ( + + ) : null} + + + + + + ); + }, + [handlePress, handleDelete, theme], + ); + + return ( + 0 ? ( + + Mark all read + + ) : null + } + > + item.id} + renderItem={renderItem} + showsVerticalScrollIndicator={false} + removeClippedSubviews={false} + contentContainerStyle={styles.listContent} + ItemSeparatorComponent={ItemSeparator} + ListHeaderComponent={ + + + Try a notification + + + Send a dummy alert now, or schedule one and minimise the app. + + +