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.
+
+
+
+ }
+ />
+
+ }
+ />
+
+
+ }
+ ListEmptyComponent={
+
+ }
+ />
+
+ );
+}
+
+const ItemSeparator = () => ;
+
+const styles = StyleSheet.create({
+ listContent: {
+ paddingBottom: 24,
+ },
+ separator: {
+ height: 10,
+ },
+ demoCard: {
+ padding: 16,
+ gap: 8,
+ marginBottom: 12,
+ },
+ demoTitle: {
+ fontSize: 15,
+ fontWeight: '800',
+ },
+ demoSubtitle: {
+ fontSize: 12.5,
+ lineHeight: 18,
+ },
+ demoButtons: {
+ flexDirection: 'row',
+ gap: 10,
+ marginTop: 4,
+ },
+ demoButton: {
+ flex: 1,
+ },
+ card: {
+ padding: 14,
+ },
+ // Unread highlight: an OPAQUE tint + border. A translucent background here
+ // makes Android render the card's elevation shadow as a grey box around it.
+ cardUnread: {
+ backgroundColor: '#EDF3FA',
+ borderColor: '#D2E0EF',
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: 12,
+ },
+ iconWrap: {
+ width: 42,
+ height: 42,
+ borderRadius: 14,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ copy: {
+ flex: 1,
+ gap: 3,
+ },
+ title: {
+ fontSize: 15,
+ fontWeight: '800',
+ },
+ body: {
+ fontSize: 12.5,
+ lineHeight: 18,
+ },
+ time: {
+ fontSize: 11,
+ fontWeight: '600',
+ marginTop: 2,
+ },
+ unreadDot: {
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ marginTop: 6,
+ },
+ deleteAction: {
+ width: 88,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4,
+ borderTopRightRadius: 12,
+ borderBottomRightRadius: 12,
+ },
+ deleteText: {
+ color: '#FFFFFF',
+ fontSize: 12,
+ fontWeight: '800',
+ },
+ markAllButton: {
+ paddingVertical: 6,
+ paddingHorizontal: 4,
+ },
+ markAllText: {
+ fontSize: 13,
+ fontWeight: '700',
+ },
+});
diff --git a/src/modules/notifications/store/notificationsSlice.ts b/src/modules/notifications/store/notificationsSlice.ts
new file mode 100644
index 0000000..366f086
--- /dev/null
+++ b/src/modules/notifications/store/notificationsSlice.ts
@@ -0,0 +1,52 @@
+import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
+
+import type { AppNotification } from '../../../types/app';
+import type { RootState } from '../../../app/store';
+import { demoNotifications } from '../../../constants/mockData';
+
+interface NotificationsState {
+ list: AppNotification[];
+}
+
+const initialState: NotificationsState = {
+ list: demoNotifications,
+};
+
+const notificationsSlice = createSlice({
+ name: 'notifications',
+ initialState,
+ reducers: {
+ setNotifications(state, action: PayloadAction) {
+ state.list = action.payload;
+ },
+ addNotification(state, action: PayloadAction) {
+ state.list.unshift(action.payload);
+ },
+ markAsRead(state, action: PayloadAction) {
+ const notification = state.list.find((item) => item.id === action.payload);
+ if (notification) {
+ notification.read = true;
+ }
+ },
+ markAllRead(state) {
+ state.list.forEach((notification) => {
+ notification.read = true;
+ });
+ },
+ removeNotification(state, action: PayloadAction) {
+ state.list = state.list.filter((item) => item.id !== action.payload);
+ },
+ clearAll(state) {
+ state.list = [];
+ },
+ },
+});
+
+export const notificationsActions = notificationsSlice.actions;
+export const notificationsReducer = notificationsSlice.reducer;
+
+// Selectors — the slice is the single source of truth for in-app notifications.
+export const selectNotifications = (state: RootState) => state.notifications.list;
+
+export const selectUnreadNotificationCount = (state: RootState) =>
+ state.notifications.list.reduce((count, item) => (item.read ? count : count + 1), 0);
diff --git a/src/modules/payments/screens/AddBeneficiaryScreen.tsx b/src/modules/payments/screens/AddBeneficiaryScreen.tsx
index 6865de5..60209d6 100644
--- a/src/modules/payments/screens/AddBeneficiaryScreen.tsx
+++ b/src/modules/payments/screens/AddBeneficiaryScreen.tsx
@@ -4,7 +4,6 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ScreenShell, Input, Button, Card, AppModal } from '../../../components';
import { useAddBeneficiaryMutation } from '../services/paymentsApi';
-import { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
import { useDispatch } from 'react-redux';
import { beneficiariesActions } from '../store/beneficiariesSlice';
@@ -12,7 +11,6 @@ import { beneficiariesActions } from '../store/beneficiariesSlice';
type Props = NativeStackScreenProps;
export default function AddBeneficiaryScreen({ navigation }: Props) {
- const theme = useTheme();
const dispatch = useDispatch();
const [addBen, { isLoading }] = useAddBeneficiaryMutation();
@@ -26,36 +24,53 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
const [otpModalVisible, setOtpModalVisible] = useState(false);
const [otp, setOtp] = useState('');
+ const [otpError, setOtpError] = useState(undefined);
- const isValid =
- form.name.length > 2 &&
+ const isValid =
+ form.name.trim().length > 2 &&
form.accountNumber.length > 6 &&
form.accountNumber === form.confirmAccountNumber &&
form.ifsc.length === 11;
const handleSubmit = () => {
if (!isValid) return;
+ setOtp('');
+ setOtpError(undefined);
setOtpModalVisible(true);
};
- const confirmOTPAndSave = async () => {
- if (otp !== '123456') return; // Mock OTP validation
-
+ const closeOtpModal = () => {
setOtpModalVisible(false);
-
+ setOtp('');
+ setOtpError(undefined);
+ };
+
+ const confirmOTPAndSave = async () => {
+ if (otp !== '123456') {
+ setOtpError('That code is incorrect. Use 123456 for this demo.');
+ return;
+ }
+
+ setOtpModalVisible(false);
+
try {
+ const nickname = form.nickname.trim() || undefined;
const newBeneficiaryObj = {
- name: form.name,
- bankName: form.ifsc.startsWith('HDFC') ? 'HDFC Bank' : 'State Bank of India', // mock logic
+ name: form.name.trim(),
+ bankName: form.ifsc.toUpperCase().startsWith('HDFC') ? 'HDFC Bank' : 'State Bank of India', // mock logic
accountMasked: form.accountNumber.slice(-4).padStart(8, 'X'),
- nickname: form.nickname,
+ nickname,
};
-
+
const payload = await addBen(newBeneficiaryObj).unwrap();
- dispatch(beneficiariesActions.addBeneficiary(payload));
+ // Save into the beneficiaries slice — the single source of truth that
+ // every payments screen reads, so the new payee shows up immediately.
+ dispatch(beneficiariesActions.addBeneficiary({ ...payload, nickname }));
navigation.goBack();
} catch (e) {
console.warn("Failed to add beneficiary", e);
+ setOtpError('We could not save this payee. Please try again.');
+ setOtpModalVisible(true);
}
};
@@ -84,12 +99,14 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
onChangeText={(v) => setForm({ ...form, confirmAccountNumber: v })}
error={form.confirmAccountNumber.length > 0 && form.accountNumber !== form.confirmAccountNumber ? "Account numbers must match" : undefined}
/>
- setForm({ ...form, ifsc: v })}
+ error={form.ifsc.length > 0 && form.ifsc.length !== 11 ? 'IFSC code must be 11 characters' : undefined}
/>
setOtpModalVisible(false)}
+ onClose={closeOtpModal}
title="Bank Authentication"
>
- {
+ setOtp(v);
+ if (otpError) setOtpError(undefined);
+ }}
+ error={otpError}
/>
-
-