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) <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-06-02 16:36:27 +05:30
co-authored by Claude Opus 4.8
parent d7907e1d64
commit 58af541536
48 changed files with 1773 additions and 157 deletions
+3
View File
@@ -17,3 +17,6 @@ coverage
android/app/.cxx/
**/ComponentDescriptors.cpp.o
**/react_codegen_rngesturehandler_codegen.dir/
# Local Claude Code session data (never push)
.claude/
+2 -1
View File
@@ -1,3 +1,4 @@
{
"java.compile.nullAnalysis.mode": "automatic"
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "automatic"
}
+2
View File
@@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Required for showing notifications on Android 13+ (Notifee). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MainApplication"
+39 -8
View File
@@ -10,12 +10,38 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
#
# Build-time JVM tuning (each flag explained):
# -Xmx2048m : max heap for the Gradle daemon (kept as-is).
# -XX:MaxMetaspaceSize=512m : cap on class-metadata space (kept as-is).
# -XX:+UseG1GC : use the G1 garbage collector - shorter, more
# predictable GC pauses than the default on large
# heaps, so build threads stall less during GC.
# -XX:+ParallelRefProcEnabled : process GC reference objects on multiple threads,
# shortening pauses on builds that allocate heavily.
# -XX:+UnlockDiagnosticVMOptions : unlock diagnostic VM options so finer GC tuning
# flags can be applied when needed.
# -XX:G1ReservePercent=5 : reserve 5% of heap as headroom to reduce costly
# promotion failures / full GCs during the build.
# -XX:InitiatingHeapOccupancyPercent=35 : start concurrent GC earlier (at 35% occupancy) to
# avoid long stop-the-world collections mid-build.
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:+UnlockDiagnosticVMOptions -XX:G1ReservePercent=5 -XX:InitiatingHeapOccupancyPercent=35
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# Run independent modules/projects concurrently instead of one after another.
# On a multi-module build this compiles modules in parallel, cutting wall-clock build time.
org.gradle.parallel=true
# Cap the number of Gradle worker processes used for parallel work.
# 4 keeps parallelism high while leaving CPU/RAM headroom for Metro, the emulator, and the IDE.
org.gradle.workers.max=4
# Enable the Gradle build cache: reuse the outputs of tasks whose inputs are unchanged
# (across builds and clean builds), so unchanged modules are not recompiled.
org.gradle.caching=true
# Explicitly enable the local (on-disk) build cache backing org.gradle.caching above.
# (The local cache is on by default once caching is enabled; this makes the intent explicit.)
org.gradle.build.cache.local.enabled=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
@@ -23,9 +49,12 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
android.useAndroidX=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -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 <task> -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
+8
View File
@@ -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',
+10
View File
@@ -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",
+1
View File
@@ -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",
+2
View File
@@ -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,
+52
View File
@@ -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',
+1 -1
View File
@@ -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;
@@ -11,7 +11,7 @@ interface LoginRoleTabsProps {
onRoleChange: (role: LoginRole) => void;
}
const ROLES: LoginRole[] = ['User', 'Employee', 'Agent'];
const ROLES: LoginRole[] = ['User', 'Agent'];
const ActiveTabBackground = () => (
<Animated.View
+6 -6
View File
@@ -36,12 +36,12 @@ export default function LoginScreen(props: Readonly<Props>) {
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<Props>) {
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<Props>) {
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<Props>) {
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 (
@@ -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;
}
+18 -18
View File
@@ -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 =
@@ -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) {
<ScreenShell
title="Cards"
subtitle="Premium controls, insights, and secure card tools"
scrollable={false}
>
<View style={styles.flex}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
contentContainerStyle={styles.scrollContent}
fixedFooter={
<Pressable
onPress={handleRequestDebitCard}
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
>
<View style={styles.heroCopy}>
<Ionicons name="add-outline" size={24} color={theme.colors.white} />
<Text style={styles.fabLabel}>Request Card</Text>
</Pressable>
}
>
<View style={styles.heroCopy}>
<View>
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
Selected wallet card
@@ -558,32 +562,15 @@ export default function CardsOverviewScreen({ navigation }: Props) {
</View>
</Card>
<View style={styles.footerSpace} />
</ScrollView>
<Pressable
onPress={handleRequestDebitCard}
style={[
styles.fab,
{ backgroundColor: theme.colors.primary },
]}
>
<Ionicons name="add-outline" size={24} color={theme.colors.white} />
<Text style={styles.fabLabel}>Request Card</Text>
</Pressable>
</View>
<View style={styles.bottomInset} />
</ScreenShell>
);
}
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,
},
});
@@ -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;
}
@@ -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(
@@ -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,
@@ -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.');
}
};
+34 -2
View File
@@ -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) {
</View>
</View>
<View style={styles.headerActions}>
<Pressable style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}>
<Pressable
style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}
onPress={() => navigation.navigate('Notifications')}
>
<Ionicons name="notifications-outline" size={22} color={theme.colors.textPrimary} />
{unreadNotifications > 0 ? (
<View style={[styles.badge, { backgroundColor: theme.colors.danger, borderColor: theme.colors.surface }]}>
<Text style={styles.badgeText}>
{unreadNotifications > 9 ? '9+' : unreadNotifications}
</Text>
</View>
) : null}
</Pressable>
<Pressable style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}>
<Pressable
style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}
onPress={() => navigation.navigate('GlobalSearch')}
>
<Ionicons name="search-outline" size={22} color={theme.colors.textPrimary} />
</Pressable>
</View>
@@ -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,
@@ -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 () => {
@@ -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 () => {
@@ -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) {
<InsuranceDocumentItem
document={item}
onView={() =>
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({
@@ -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.`,
)
}
/>
@@ -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 () => {
@@ -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<HomeStackParamList, 'Notifications'>;
type IconName = React.ComponentProps<typeof Ionicons>['name'];
const CATEGORY_ICON: Record<NotificationCategory, IconName> = {
transaction: 'swap-horizontal-outline',
security: 'shield-checkmark-outline',
offer: 'pricetag-outline',
reminder: 'alarm-outline',
system: 'information-circle-outline',
};
const categoryColor = (
category: NotificationCategory,
colors: ReturnType<typeof useTheme>['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 = () => (
<Pressable
onPress={() => handleDelete(item.id)}
style={[styles.deleteAction, { backgroundColor: theme.colors.danger }]}
>
<Ionicons name="trash-outline" size={22} color={theme.colors.white} />
<Text style={styles.deleteText}>Delete</Text>
</Pressable>
);
return (
<Animated.View
exiting={vanishExit}
layout={LinearTransition.springify().damping(16).stiffness(170).mass(0.6)}
>
<Swipeable
renderRightActions={renderRightActions}
overshootRight={false}
rightThreshold={40}
friction={2}
>
<Pressable onPress={() => handlePress(item)}>
<Card style={[styles.card, !item.read && styles.cardUnread]}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: `${accent}18` }]}>
<Ionicons name={CATEGORY_ICON[item.category]} size={20} color={accent} />
</View>
<View style={styles.copy}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>
{item.title}
</Text>
<Text style={[styles.body, { color: theme.colors.textSecondary }]}>
{item.body}
</Text>
<Text style={[styles.time, { color: theme.colors.textMuted }]}>
{formatRelativeTime(item.createdAt)}
</Text>
</View>
{!item.read ? (
<View style={[styles.unreadDot, { backgroundColor: theme.colors.primary }]} />
) : null}
</View>
</Card>
</Pressable>
</Swipeable>
</Animated.View>
);
},
[handlePress, handleDelete, theme],
);
return (
<ScreenShell
title="Notifications"
subtitle={subtitle}
scrollable={false}
rightContent={
unreadCount > 0 ? (
<Pressable onPress={handleMarkAll} hitSlop={8} style={styles.markAllButton}>
<Text style={[styles.markAllText, { color: theme.colors.primary }]}>Mark all read</Text>
</Pressable>
) : null
}
>
<FlatList
data={sorted}
keyExtractor={(item) => item.id}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
removeClippedSubviews={false}
contentContainerStyle={styles.listContent}
ItemSeparatorComponent={ItemSeparator}
ListHeaderComponent={
<Card style={styles.demoCard}>
<Text style={[styles.demoTitle, { color: theme.colors.textPrimary }]}>
Try a notification
</Text>
<Text style={[styles.demoSubtitle, { color: theme.colors.textSecondary }]}>
Send a dummy alert now, or schedule one and minimise the app.
</Text>
<View style={styles.demoButtons}>
<Button
title="Send now"
fullWidth={false}
style={styles.demoButton}
onPress={handleSendNow}
leftAccessory={
<Ionicons name="notifications-outline" size={18} color={theme.colors.white} />
}
/>
<Button
title="In 5s"
variant="secondary"
fullWidth={false}
style={styles.demoButton}
onPress={handleSchedule}
leftAccessory={
<Ionicons name="time-outline" size={18} color={theme.colors.textPrimary} />
}
/>
</View>
</Card>
}
ListEmptyComponent={
<EmptyState
title="No notifications"
description="Account alerts, reminders, and offers will show up here."
/>
}
/>
</ScreenShell>
);
}
const ItemSeparator = () => <View style={styles.separator} />;
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',
},
});
@@ -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<AppNotification[]>) {
state.list = action.payload;
},
addNotification(state, action: PayloadAction<AppNotification>) {
state.list.unshift(action.payload);
},
markAsRead(state, action: PayloadAction<string>) {
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<string>) {
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);
@@ -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<PaymentsStackParamList, 'AddBeneficiary'>;
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<string | undefined>(undefined);
const isValid =
form.name.length > 2 &&
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 closeOtpModal = () => {
setOtpModalVisible(false);
setOtp('');
setOtpError(undefined);
};
const confirmOTPAndSave = async () => {
if (otp !== '123456') return; // Mock OTP validation
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);
}
};
@@ -88,8 +103,10 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
label="IFSC Code"
placeholder="e.g. SBIN0001234"
autoCapitalize="characters"
maxLength={11}
value={form.ifsc}
onChangeText={(v) => setForm({ ...form, ifsc: v })}
error={form.ifsc.length > 0 && form.ifsc.length !== 11 ? 'IFSC code must be 11 characters' : undefined}
/>
<Input
label="Nickname (Optional)"
@@ -109,7 +126,7 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
<AppModal
visible={otpModalVisible}
onClose={() => setOtpModalVisible(false)}
onClose={closeOtpModal}
title="Bank Authentication"
>
<View style={{ gap: 16 }}>
@@ -119,7 +136,11 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
keyboardType="number-pad"
maxLength={6}
value={otp}
onChangeText={setOtp}
onChangeText={(v) => {
setOtp(v);
if (otpError) setOtpError(undefined);
}}
error={otpError}
/>
<Button
title="Confirm Addition"
@@ -4,7 +4,8 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, Card, Input } from '../../../components';
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
import { useAppSelector } from '../../../app/hooks';
import { selectBeneficiaries } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import type { PaymentsStackParamList } from '../../../navigation/types';
@@ -13,7 +14,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'BeneficiariesList'>
export default function BeneficiariesListScreen({ navigation }: Props) {
const theme = useTheme();
const { data: beneficiaries = [] } = useGetBeneficiariesQuery();
const beneficiaries = useAppSelector(selectBeneficiaries);
const [search, setSearch] = useState('');
const filtered = beneficiaries.filter(b =>
@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, ScrollView } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { ScreenShell, Button, Card, AppModal, Input } from '../../../components';
import { useGetBeneficiariesQuery, useSubmitBankTransferMutation } from '../services/paymentsApi';
import { useSubmitBankTransferMutation } from '../services/paymentsApi';
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
import { accountActions } from '../../home/store/accountSlice';
@@ -16,8 +17,7 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
const dispatch = useDispatch();
const { beneficiaryId, transferType, amount, remarks } = route.params;
const { data: beneficiaries } = useGetBeneficiariesQuery();
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
const beneficiary = useSelector(selectBeneficiaryById(beneficiaryId));
const [submitTransfer, { isLoading }] = useSubmitBankTransferMutation();
const [otpModalVisible, setOtpModalVisible] = useState(false);
@@ -5,7 +5,7 @@ import { useDispatch, useSelector } from 'react-redux';
import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, Input, Button, Card } from '../../../components';
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
import type { RootState } from '../../../app/store';
@@ -18,8 +18,7 @@ export function PaymentFormScreen({ navigation, route }: Props) {
const theme = useTheme();
const beneficiaryId = route.params?.beneficiaryId;
const transferType = route.params?.transferType;
const { data: beneficiaries } = useGetBeneficiariesQuery();
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
const beneficiary = useSelector(selectBeneficiaryById(beneficiaryId));
const dispatch = useDispatch();
const [amount, setAmount] = useState('');
@@ -4,7 +4,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Card, ScreenShell } from '../../../components';
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
import { selectBeneficiaries } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme';
import { useAppSelector } from '../../../app/hooks';
import type { PaymentsStackParamList } from '../../../navigation/types';
@@ -16,7 +16,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentsHub'>;
export default function PaymentsHomeScreen({ navigation }: Props) {
const theme = useTheme();
const transactions = useAppSelector(state => state.account.transactions);
const { data: beneficiaries = [] } = useGetBeneficiariesQuery();
const beneficiaries = useAppSelector(selectBeneficiaries);
// Simulated stats
const dailyLimit = 500000;
@@ -4,7 +4,8 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, Card } from '../../../components';
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
import { useAppSelector } from '../../../app/hooks';
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
@@ -13,8 +14,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransferTypeSelecti
export default function TransferTypeScreen({ navigation, route }: Props) {
const theme = useTheme();
const { beneficiaryId } = route.params;
const { data: beneficiaries } = useGetBeneficiariesQuery();
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
const beneficiary = useAppSelector(selectBeneficiaryById(beneficiaryId));
const handleSelect = (type: 'IMPS' | 'NEFT' | 'RTGS') => {
navigation.navigate('PaymentForm', { beneficiaryId, transferType: type });
@@ -1,5 +1,6 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { Beneficiary } from '../../../types/app';
import type { RootState } from '../../../app/store';
import { demoBeneficiaries } from '../../../constants/mockData';
interface BeneficiariesState {
@@ -34,3 +35,9 @@ const beneficiariesSlice = createSlice({
export const beneficiariesActions = beneficiariesSlice.actions;
export const beneficiariesReducer = beneficiariesSlice.reducer;
// Selectors — the slice is the single source of truth for saved payees, so a
// newly added beneficiary is immediately visible everywhere it is read.
export const selectBeneficiaries = (state: RootState) => state.beneficiaries.list;
export const selectBeneficiaryById = (id: string) => (state: RootState) =>
state.beneficiaries.list.find((beneficiary) => beneficiary.id === id);
@@ -0,0 +1,207 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
FlatList,
Keyboard,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { EmptyState, Input, ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { selectSelectedAccount } from '../../home/store/accountSelectors';
import { useTheme } from '../../../theme';
import type { HomeStackParamList, MainTabParamList } from '../../../navigation/types';
import {
buildUserSearchCatalog,
filterFeatures,
type SearchableFeature,
} from '../searchCatalog';
type Props = CompositeScreenProps<
NativeStackScreenProps<HomeStackParamList, 'GlobalSearch'>,
BottomTabScreenProps<MainTabParamList>
>;
export default function GlobalSearchScreen({ navigation }: Props) {
const theme = useTheme();
const selectedAccount = useAppSelector(selectSelectedAccount);
const [query, setQuery] = useState('');
const catalog = useMemo(
() => buildUserSearchCatalog({ primaryAccountId: selectedAccount?.id }),
[selectedAccount?.id],
);
const results = useMemo(() => filterFeatures(catalog, query), [catalog, query]);
const trimmedQuery = query.trim();
const isSearching = trimmedQuery.length > 0;
const handleSelect = useCallback(
(feature: SearchableFeature) => {
Keyboard.dismiss();
// Dismiss the search screen first so it isn't left behind on the Home
// stack, then route to the feature (which may switch tabs).
navigation.goBack();
feature.navigate(navigation);
},
[navigation],
);
const renderItem = useCallback(
({ item }: { item: SearchableFeature }) => (
<Pressable
onPress={() => handleSelect(item)}
style={({ pressed }) => [
styles.resultRow,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
opacity: pressed ? 0.7 : 1,
},
]}
>
<View style={[styles.resultIcon, { backgroundColor: `${item.iconColor}18` }]}>
<Ionicons name={item.icon} size={20} color={item.iconColor} />
</View>
<View style={styles.resultCopy}>
<Text style={[styles.resultTitle, { color: theme.colors.textPrimary }]} numberOfLines={1}>
{item.title}
</Text>
<Text
style={[styles.resultSubtitle, { color: theme.colors.textSecondary }]}
numberOfLines={1}
>
{item.subtitle}
</Text>
</View>
<View style={styles.resultTrailing}>
<View style={[styles.moduleChip, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.moduleChipText, { color: theme.colors.textMuted }]}>
{item.module}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={theme.colors.textMuted} />
</View>
</Pressable>
),
[handleSelect, theme],
);
const listHeader = (
<Text style={[styles.listHeader, { color: theme.colors.textMuted }]}>
{isSearching
? `${results.length} ${results.length === 1 ? 'result' : 'results'} for "${trimmedQuery}"`
: 'Browse everything you can do'}
</Text>
);
return (
<ScreenShell title="Search" subtitle="Find any feature" scrollable={false}>
<Input
value={query}
onChangeText={setQuery}
placeholder="Search for a feature, e.g. fixed deposit"
autoFocus
autoCorrect={false}
autoCapitalize="none"
returnKeyType="search"
leftAccessory={
<Ionicons name="search-outline" size={20} color={theme.colors.textMuted} />
}
rightAccessory={
query.length > 0 ? (
<Pressable onPress={() => setQuery('')} hitSlop={12}>
<Ionicons name="close-circle" size={20} color={theme.colors.textMuted} />
</Pressable>
) : undefined
}
/>
<FlatList
data={results}
keyExtractor={(item) => item.id}
renderItem={renderItem}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false}
ListHeaderComponent={listHeader}
contentContainerStyle={styles.listContent}
style={styles.list}
ListEmptyComponent={
<EmptyState
title="No matches"
description={`We couldn't find anything for "${trimmedQuery}". Try a different word.`}
/>
}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
list: {
flex: 1,
},
listContent: {
gap: 10,
paddingBottom: 24,
},
listHeader: {
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.4,
textTransform: 'uppercase',
marginBottom: 4,
},
resultRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
paddingVertical: 12,
paddingHorizontal: 14,
borderRadius: 16,
borderWidth: 1,
},
resultIcon: {
width: 44,
height: 44,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
resultCopy: {
flex: 1,
gap: 2,
},
resultTitle: {
fontSize: 15,
fontWeight: '700',
},
resultSubtitle: {
fontSize: 12.5,
fontWeight: '500',
},
resultTrailing: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
moduleChip: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
moduleChipText: {
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
});
+675
View File
@@ -0,0 +1,675 @@
import React from 'react';
import type { CompositeNavigationProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import type { HomeStackParamList, MainTabParamList } from '../../navigation/types';
export type IconName = React.ComponentProps<typeof Ionicons>['name'];
/**
* Navigation prop available to the global search screen. It is composed of the
* Home stack (where the search screen lives) and the bottom-tab navigator, so a
* single feature entry can navigate to any screen in the user app — its own tab
* or another one.
*/
export type GlobalSearchNavigation = CompositeNavigationProp<
NativeStackNavigationProp<HomeStackParamList, 'GlobalSearch'>,
BottomTabNavigationProp<MainTabParamList>
>;
/** A single searchable feature/destination in the user app. */
export type SearchableFeature = {
/** Stable id, used as the list key. */
id: string;
/** Human-readable feature name shown as the result title. */
title: string;
/** Short description shown under the title. */
subtitle: string;
/** Module/group this feature belongs to (shown as a chip and used in matching). */
module: string;
icon: IconName;
iconColor: string;
/** Extra terms (synonyms, abbreviations) so search matches what people actually type. */
keywords: string[];
/** Performs the navigation to this feature. */
navigate: (nav: GlobalSearchNavigation) => void;
};
/** Context the catalog needs to build account-specific destinations. */
export type SearchCatalogContext = {
/** Account to deep-link account-specific features to (e.g. mini statement). */
primaryAccountId?: string;
};
const MODULE_COLORS = {
accounts: '#124577',
payments: '#10B981',
cards: '#6366F1',
services: '#3B82F6',
loans: '#F59E0B',
insurance: '#EC4899',
profile: '#8B5CF6',
} as const;
/**
* Builds the full list of searchable features for the user (customer) app.
* Account-specific entries are only included when an account id is available.
*/
export function buildUserSearchCatalog(
context: SearchCatalogContext = {},
): SearchableFeature[] {
const { primaryAccountId } = context;
const features: SearchableFeature[] = [
// ---- Accounts (Home) ----
{
id: 'accounts-overview',
title: 'Home',
subtitle: 'Your accounts and balances',
module: 'Accounts',
icon: 'home-outline',
iconColor: MODULE_COLORS.accounts,
keywords: ['dashboard', 'balance', 'accounts', 'main'],
navigate: (nav) => nav.navigate('Home', { screen: 'HomeMain' }),
},
// ---- Payments ----
{
id: 'payments-hub',
title: 'Payments',
subtitle: 'Send money and manage transfers',
module: 'Payments',
icon: 'wallet-outline',
iconColor: MODULE_COLORS.payments,
keywords: ['transfer', 'send money', 'pay', 'imps', 'neft', 'rtgs', 'fund transfer'],
navigate: (nav) => nav.navigate('Payments', { screen: 'PaymentsHub' }),
},
{
id: 'payments-beneficiaries',
title: 'Beneficiaries',
subtitle: 'View and manage your payees',
module: 'Payments',
icon: 'people-outline',
iconColor: MODULE_COLORS.payments,
keywords: ['payee', 'recipient', 'contacts', 'manage payee'],
navigate: (nav) => nav.navigate('Payments', { screen: 'BeneficiariesList' }),
},
{
id: 'payments-add-beneficiary',
title: 'Add beneficiary',
subtitle: 'Add a new payee to send money to',
module: 'Payments',
icon: 'person-add-outline',
iconColor: MODULE_COLORS.payments,
keywords: ['new payee', 'add payee', 'add recipient'],
navigate: (nav) => nav.navigate('Payments', { screen: 'AddBeneficiary' }),
},
// ---- Cards ----
{
id: 'cards-overview',
title: 'My cards',
subtitle: 'View and manage your cards',
module: 'Cards',
icon: 'card-outline',
iconColor: MODULE_COLORS.cards,
keywords: ['debit card', 'credit card', 'card controls', 'freeze card', 'block card'],
navigate: (nav) => nav.navigate('Cards', { screen: 'CardsOverview' }),
},
{
id: 'cards-apply-credit',
title: 'Apply for a credit card',
subtitle: 'Check offers and apply',
module: 'Cards',
icon: 'add-circle-outline',
iconColor: MODULE_COLORS.cards,
keywords: ['new credit card', 'credit card application'],
navigate: (nav) => nav.navigate('Cards', { screen: 'ApplyCreditCard' }),
},
{
id: 'cards-request-physical',
title: 'Request a debit card',
subtitle: 'Order a new physical card',
module: 'Cards',
icon: 'mail-outline',
iconColor: MODULE_COLORS.cards,
keywords: ['physical card', 'new debit card', 'order card', 'reissue card'],
navigate: (nav) => nav.navigate('Cards', { screen: 'RequestPhysicalCard' }),
},
// ---- Services / Deposits ----
{
id: 'services-hub',
title: 'Service desk',
subtitle: 'All service options in one place',
module: 'Services',
icon: 'grid-outline',
iconColor: MODULE_COLORS.services,
keywords: ['services', 'hub', 'help', 'requests'],
navigate: (nav) => nav.navigate('Services', { screen: 'ServicesHub' }),
},
{
id: 'services-deposits',
title: 'Deposits',
subtitle: 'View your fixed and recurring deposits',
module: 'Services',
icon: 'briefcase-outline',
iconColor: MODULE_COLORS.services,
keywords: ['fd', 'rd', 'fixed deposit', 'recurring deposit', 'term deposit'],
navigate: (nav) => nav.navigate('Services', { screen: 'DepositsOverview' }),
},
{
id: 'services-open-fd',
title: 'Open a fixed deposit',
subtitle: 'Start a new fixed deposit',
module: 'Services',
icon: 'trending-up-outline',
iconColor: MODULE_COLORS.services,
keywords: ['fd', 'new fixed deposit', 'invest'],
navigate: (nav) => nav.navigate('Services', { screen: 'OpenFixedDeposit' }),
},
{
id: 'services-open-rd',
title: 'Open a recurring deposit',
subtitle: 'Start a new recurring deposit',
module: 'Services',
icon: 'repeat-outline',
iconColor: MODULE_COLORS.services,
keywords: ['rd', 'new recurring deposit', 'monthly savings'],
navigate: (nav) => nav.navigate('Services', { screen: 'OpenRecurringDeposit' }),
},
{
id: 'services-deposit-calculator',
title: 'Deposit calculator',
subtitle: 'Estimate FD and RD returns',
module: 'Services',
icon: 'calculator-outline',
iconColor: MODULE_COLORS.services,
keywords: ['fd calculator', 'rd calculator', 'interest', 'maturity', 'returns'],
navigate: (nav) => nav.navigate('Services', { screen: 'DepositCalculator' }),
},
{
id: 'services-cheque',
title: 'Cheque services',
subtitle: 'Cheque book, stop payment and tracking',
module: 'Services',
icon: 'book-outline',
iconColor: MODULE_COLORS.services,
keywords: ['cheque', 'check', 'chequebook'],
navigate: (nav) => nav.navigate('Services', { screen: 'ChequeServicesHub' }),
},
{
id: 'services-cheque-book',
title: 'Request a cheque book',
subtitle: 'Order a new cheque book',
module: 'Services',
icon: 'documents-outline',
iconColor: MODULE_COLORS.services,
keywords: ['chequebook', 'new cheque book', 'order cheques'],
navigate: (nav) => nav.navigate('Services', { screen: 'ChequeBookRequest' }),
},
{
id: 'services-stop-payment',
title: 'Stop a cheque payment',
subtitle: 'Block payment on a cheque',
module: 'Services',
icon: 'hand-left-outline',
iconColor: MODULE_COLORS.services,
keywords: ['stop cheque', 'block cheque', 'stop payment'],
navigate: (nav) => nav.navigate('Services', { screen: 'StopPayment' }),
},
{
id: 'services-positive-pay',
title: 'Positive Pay',
subtitle: 'Confirm cheque details before clearing',
module: 'Services',
icon: 'shield-checkmark-outline',
iconColor: MODULE_COLORS.services,
keywords: ['cheque verification', 'positive pay system', 'pps'],
navigate: (nav) => nav.navigate('Services', { screen: 'PositivePay' }),
},
{
id: 'services-certificates',
title: 'Certificates',
subtitle: 'Interest and balance certificates',
module: 'Services',
icon: 'document-text-outline',
iconColor: MODULE_COLORS.services,
keywords: ['interest certificate', 'balance certificate', 'tds', 'statement'],
navigate: (nav) => nav.navigate('Services', { screen: 'CertificatesHub' }),
},
{
id: 'services-requests',
title: 'Service requests',
subtitle: 'Track and raise service requests',
module: 'Services',
icon: 'git-pull-request-outline',
iconColor: MODULE_COLORS.services,
keywords: ['complaint', 'request', 'ticket', 'support request'],
navigate: (nav) => nav.navigate('Services', { screen: 'ServiceRequestsHub' }),
},
{
id: 'services-special-instructions',
title: 'Special instructions',
subtitle: 'Send a standing instruction to your branch',
module: 'Services',
icon: 'create-outline',
iconColor: MODULE_COLORS.services,
keywords: ['standing instruction', 'note to branch'],
navigate: (nav) => nav.navigate('Services', { screen: 'SpecialInstructions' }),
},
{
id: 'services-form-15g15h',
title: 'Form 15G / 15H',
subtitle: 'Submit your tax declaration',
module: 'Services',
icon: 'reader-outline',
iconColor: MODULE_COLORS.services,
keywords: ['tax', 'tds', 'declaration', '15g', '15h'],
navigate: (nav) => nav.navigate('Services', { screen: 'Form15G15H' }),
},
{
id: 'services-branch-locator',
title: 'Find a branch or ATM',
subtitle: 'Locate nearby branches and ATMs',
module: 'Services',
icon: 'map-outline',
iconColor: MODULE_COLORS.services,
keywords: ['atm', 'branch', 'nearby', 'location', 'directions'],
navigate: (nav) => nav.navigate('Services', { screen: 'BranchLocator' }),
},
{
id: 'services-awareness',
title: 'Cyber security awareness',
subtitle: 'Stay safe from fraud and scams',
module: 'Services',
icon: 'shield-half-outline',
iconColor: MODULE_COLORS.services,
keywords: ['fraud', 'scam', 'safety', 'security tips', 'awareness'],
navigate: (nav) => nav.navigate('Services', { screen: 'AwarenessList' }),
},
{
id: 'services-financial-protection',
title: 'Protection & schemes',
subtitle: 'Government schemes and deposit protection',
module: 'Services',
icon: 'umbrella-outline',
iconColor: MODULE_COLORS.services,
keywords: ['schemes', 'protection', 'social security', 'pmjjby', 'pmsby'],
navigate: (nav) => nav.navigate('Services', { screen: 'FinancialProtectionMain' }),
},
{
id: 'services-dicgc',
title: 'Deposit insurance (DICGC)',
subtitle: 'How your deposits are protected',
module: 'Services',
icon: 'lock-closed-outline',
iconColor: MODULE_COLORS.services,
keywords: ['dicgc', 'deposit insurance', 'guarantee'],
navigate: (nav) => nav.navigate('Services', { screen: 'DicgcHub' }),
},
{
id: 'services-deaf',
title: 'Unclaimed deposits (DEAF)',
subtitle: 'Claim inactive or unclaimed deposits',
module: 'Services',
icon: 'time-outline',
iconColor: MODULE_COLORS.services,
keywords: ['deaf', 'unclaimed', 'inactive account', 'dormant'],
navigate: (nav) => nav.navigate('Services', { screen: 'DeafHub' }),
},
// ---- Loans (Home > LoanFlow) ----
{
id: 'loans-dashboard',
title: 'Loans',
subtitle: 'View and manage your loans',
module: 'Loans',
icon: 'cash-outline',
iconColor: MODULE_COLORS.loans,
keywords: ['loan', 'borrow', 'emi', 'home loan', 'personal loan', 'gold loan'],
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanDashboard' }),
},
{
id: 'loans-types',
title: 'Explore loan types',
subtitle: 'Browse available loan products',
module: 'Loans',
icon: 'list-outline',
iconColor: MODULE_COLORS.loans,
keywords: ['loan products', 'types of loan'],
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanTypesListing' }),
},
{
id: 'loans-eligibility',
title: 'Check loan eligibility',
subtitle: 'See how much you can borrow',
module: 'Loans',
icon: 'checkmark-circle-outline',
iconColor: MODULE_COLORS.loans,
keywords: ['eligibility', 'qualify', 'how much loan'],
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanEligibilityCheck', params: {} }),
},
{
id: 'loans-calculator',
title: 'EMI calculator',
subtitle: 'Estimate your loan EMIs',
module: 'Loans',
icon: 'calculator-outline',
iconColor: MODULE_COLORS.loans,
keywords: ['emi', 'loan calculator', 'interest', 'repayment'],
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanCalculator', params: {} }),
},
{
id: 'loans-tracking',
title: 'Track loan application',
subtitle: 'Check your application status',
module: 'Loans',
icon: 'navigate-outline',
iconColor: MODULE_COLORS.loans,
keywords: ['application status', 'loan status'],
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanTracking' }),
},
{
id: 'loans-history',
title: 'Loan history',
subtitle: 'See your past and active loans',
module: 'Loans',
icon: 'receipt-outline',
iconColor: MODULE_COLORS.loans,
keywords: ['past loans', 'closed loans'],
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanHistory' }),
},
// ---- Insurance (Home > InsuranceFlow) ----
{
id: 'insurance-dashboard',
title: 'Insurance',
subtitle: 'Manage your policies and cover',
module: 'Insurance',
icon: 'shield-outline',
iconColor: MODULE_COLORS.insurance,
keywords: ['policy', 'cover', 'life insurance', 'health insurance'],
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsuranceDashboard' }),
},
{
id: 'insurance-buy',
title: 'Buy insurance',
subtitle: 'Explore and purchase a new plan',
module: 'Insurance',
icon: 'add-circle-outline',
iconColor: MODULE_COLORS.insurance,
keywords: ['new policy', 'buy policy', 'get cover'],
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'BuyInsurance' }),
},
{
id: 'insurance-policies',
title: 'My policies',
subtitle: 'View your active insurance policies',
module: 'Insurance',
icon: 'folder-open-outline',
iconColor: MODULE_COLORS.insurance,
keywords: ['active policies', 'my cover'],
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsurancePolicies' }),
},
{
id: 'insurance-renewals',
title: 'Insurance renewals',
subtitle: 'Pay premiums and renew policies',
module: 'Insurance',
icon: 'refresh-outline',
iconColor: MODULE_COLORS.insurance,
keywords: ['renew', 'premium', 'pay premium', 'due'],
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsuranceRenewals' }),
},
{
id: 'insurance-claims',
title: 'Insurance claims',
subtitle: 'File and track insurance claims',
module: 'Insurance',
icon: 'medkit-outline',
iconColor: MODULE_COLORS.insurance,
keywords: ['claim', 'file claim', 'claim status'],
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsuranceClaims' }),
},
{
id: 'insurance-documents',
title: 'Insurance documents',
subtitle: 'Upload and view policy documents',
module: 'Insurance',
icon: 'document-attach-outline',
iconColor: MODULE_COLORS.insurance,
keywords: ['documents', 'kyc', 'upload'],
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsuranceDocuments' }),
},
{
id: 'insurance-calculator',
title: 'Insurance calculator',
subtitle: 'Estimate the cover you need',
module: 'Insurance',
icon: 'calculator-outline',
iconColor: MODULE_COLORS.insurance,
keywords: ['premium calculator', 'cover estimate'],
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsuranceCalculator' }),
},
// ---- Profile & settings ----
{
id: 'profile-main',
title: 'Profile',
subtitle: 'Your profile and app settings',
module: 'Profile',
icon: 'person-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['account', 'settings', 'me'],
navigate: (nav) => nav.navigate('Profile', { screen: 'ProfileMain' }),
},
{
id: 'profile-edit',
title: 'Edit profile',
subtitle: 'Update your personal details',
module: 'Profile',
icon: 'create-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['update details', 'change name', 'contact details'],
navigate: (nav) => nav.navigate('Profile', { screen: 'EditProfile' }),
},
{
id: 'profile-accounts',
title: 'Account management',
subtitle: 'Manage your linked accounts',
module: 'Profile',
icon: 'albums-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['linked accounts', 'manage accounts'],
navigate: (nav) => nav.navigate('Profile', { screen: 'AccountManagement' }),
},
{
id: 'profile-add-account',
title: 'Add an account',
subtitle: 'Link a new account',
module: 'Profile',
icon: 'add-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['new account', 'link account', 'open account'],
navigate: (nav) => nav.navigate('Profile', { screen: 'AddAccount' }),
},
{
id: 'profile-change-pin',
title: 'Change PIN',
subtitle: 'Update your login PIN',
module: 'Profile',
icon: 'keypad-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['pin', 'reset pin', 'mpin'],
navigate: (nav) => nav.navigate('Profile', { screen: 'ChangePin' }),
},
{
id: 'profile-change-password',
title: 'Change password',
subtitle: 'Update your account password',
module: 'Profile',
icon: 'lock-closed-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['password', 'reset password'],
navigate: (nav) => nav.navigate('Profile', { screen: 'ChangePassword' }),
},
{
id: 'profile-security',
title: 'Security settings',
subtitle: 'Biometrics and account security',
module: 'Profile',
icon: 'finger-print-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['biometric', 'fingerprint', 'face id', 'security'],
navigate: (nav) => nav.navigate('Profile', { screen: 'SecuritySettings' }),
},
{
id: 'profile-privacy',
title: 'Privacy settings',
subtitle: 'Control your data and privacy',
module: 'Profile',
icon: 'eye-off-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['privacy', 'data', 'permissions'],
navigate: (nav) => nav.navigate('Profile', { screen: 'PrivacySettings' }),
},
{
id: 'profile-notifications',
title: 'Notification settings',
subtitle: 'Manage alerts and notifications',
module: 'Profile',
icon: 'notifications-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['alerts', 'notifications', 'reminders'],
navigate: (nav) => nav.navigate('Profile', { screen: 'NotificationSettings' }),
},
{
id: 'profile-language',
title: 'Language settings',
subtitle: 'Change the app language',
module: 'Profile',
icon: 'language-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['language', 'locale', 'translate'],
navigate: (nav) => nav.navigate('Profile', { screen: 'LanguageSettings' }),
},
{
id: 'profile-help',
title: 'Help & support',
subtitle: 'Get help and contact us',
module: 'Profile',
icon: 'help-buoy-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['help', 'support', 'contact', 'faq'],
navigate: (nav) => nav.navigate('Profile', { screen: 'HelpSupport' }),
},
{
id: 'profile-legal',
title: 'Legal information',
subtitle: 'Terms, policies and disclosures',
module: 'Profile',
icon: 'document-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['terms', 'privacy policy', 'legal', 'conditions'],
navigate: (nav) => nav.navigate('Profile', { screen: 'LegalInfo' }),
},
{
id: 'profile-about',
title: 'About this app',
subtitle: 'App version and information',
module: 'Profile',
icon: 'information-circle-outline',
iconColor: MODULE_COLORS.profile,
keywords: ['version', 'about', 'app info'],
navigate: (nav) => nav.navigate('Profile', { screen: 'AboutApp' }),
},
];
// Account-specific destinations require an account to deep-link into.
if (primaryAccountId) {
features.push(
{
id: 'accounts-mini-statement',
title: 'Mini statement',
subtitle: 'Recent transactions on your account',
module: 'Accounts',
icon: 'list-outline',
iconColor: MODULE_COLORS.accounts,
keywords: ['transactions', 'recent', 'statement', 'history'],
navigate: (nav) =>
nav.navigate('Home', {
screen: 'MiniStatement',
params: { accountId: primaryAccountId },
}),
},
{
id: 'accounts-details',
title: 'Account details',
subtitle: 'Your account number and information',
module: 'Accounts',
icon: 'information-circle-outline',
iconColor: MODULE_COLORS.accounts,
keywords: ['account number', 'ifsc', 'details', 'branch'],
navigate: (nav) =>
nav.navigate('Home', {
screen: 'AccountDetails',
params: { accountId: primaryAccountId },
}),
},
);
}
return features;
}
/**
* Filters and ranks features for a query. An empty query returns the full
* catalog (so the screen doubles as a browsable directory). Every whitespace-
* separated token must appear somewhere in the feature's searchable text;
* matches in the title (and especially title prefixes) rank highest.
*/
export function filterFeatures(
features: SearchableFeature[],
rawQuery: string,
): SearchableFeature[] {
const query = rawQuery.trim().toLowerCase();
if (!query) {
return features;
}
const tokens = query.split(/\s+/);
const scored = features
.map((feature) => {
const title = feature.title.toLowerCase();
const haystack = [feature.title, feature.subtitle, feature.module, ...feature.keywords]
.join(' ')
.toLowerCase();
if (!tokens.every((token) => haystack.includes(token))) {
return null;
}
let score = 0;
if (title.startsWith(query)) {
score += 100;
} else if (title.includes(query)) {
score += 60;
}
if (feature.keywords.some((keyword) => keyword.toLowerCase().startsWith(query))) {
score += 30;
}
score += tokens.filter((token) => title.includes(token)).length * 8;
return { feature, score };
})
.filter((entry): entry is { feature: SearchableFeature; score: number } => entry !== null);
scored.sort((a, b) => b.score - a.score);
return scored.map((entry) => entry.feature);
}
@@ -24,7 +24,7 @@ export default function CertificateListScreen({ route }: Props) {
}));
const handleDownload = (docName: string) => {
toast('Download Started', 'success', `${docName} is being securely downloaded to your device.`);
toast('Download started', 'success', `${docName} is being saved securely to your device.`);
};
return (
@@ -20,7 +20,7 @@ export default function ChequeBookRequestScreen({ navigation }: Props) {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
toast('Request Submitted', 'success', 'Your cheque book will be delivered in 3-5 days.');
toast('Request submitted', 'success', 'Your cheque book will arrive in 3 to 5 days.');
navigation.goBack();
}, 1500);
};
@@ -18,13 +18,13 @@ export default function Form15G15HScreen({ navigation }: Props) {
const handleSubmit = () => {
if (!panNumber || !estimatedIncome) {
toast('Validation Failed', 'error', 'PAN and estimated income are required.');
toast('Some details are missing', 'error', 'Please enter your PAN and estimated income to continue.');
return;
}
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
toast('Form Submitted successfully', 'success', `Your Form ${formType} has been recorded for the current FY.`);
toast('Form submitted', 'success', `Your Form ${formType} has been recorded for the current financial year.`);
navigation.goBack();
}, 1500);
};
@@ -78,7 +78,7 @@ export default function Form15G15HScreen({ navigation }: Props) {
<Text style={{ fontWeight: '700', color: theme.colors.textPrimary }}>Form 15G - FY 24-25</Text>
<Text style={{ fontSize: 12, color: theme.colors.textSecondary }}>Submitted April 2025</Text>
</View>
<Button title="Download" variant="ghost" onPress={() => toast('Downloading...', 'info')} />
<Button title="Download" variant="ghost" onPress={() => toast('Download started', 'info', 'Your form is being prepared for download.')} />
</Card>
</View>
</ScreenShell>
@@ -20,13 +20,13 @@ export default function PositivePayScreen({ navigation }: Props) {
const handleSubmit = () => {
if (!chequeNumber || !amount || !payee) {
toast('Incomplete Form', 'error', 'Please fill all required details.');
toast('Some details are missing', 'error', 'Please fill in all the required details to continue.');
return;
}
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
toast('Verification Submitted', 'success', `Details for Cheque #${chequeNumber} saved securely.`);
toast('Cheque details submitted', 'success', `Your details for cheque #${chequeNumber} have been saved securely.`);
navigation.goBack();
}, 1500);
};
@@ -80,9 +80,9 @@ export default function ServicesHubScreen({ navigation }: Props) {
iconColor: '#0EA5E9',
onPress: () =>
toast(
'Support chat screen pending',
'Support chat coming soon',
'info',
'This feature is coming in the next update.',
'This feature will be available in the next update.',
),
},
{
@@ -91,7 +91,7 @@ export default function ServicesHubScreen({ navigation }: Props) {
icon: 'help-circle-outline',
iconColor: '#8B5CF6',
onPress: () =>
toast('Help center pending', 'info', 'Coming soon.'),
toast('Help center coming soon', 'info', 'This feature will be available shortly.'),
},
{
id: 'cyber_security',
@@ -17,13 +17,13 @@ export default function SpecialInstructionsScreen({ navigation }: Props) {
const handleSubmit = () => {
if (!title || !description) {
toast('Empty fields', 'error', 'Title and description are required.');
toast('Some details are missing', 'error', 'Please add a title and description before you continue.');
return;
}
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
toast('Request Logged', 'success', 'Your special instruction has been submitted to the branch.');
toast('Request submitted', 'success', 'Your special instruction has been sent to your branch.');
navigation.goBack();
}, 1500);
};
@@ -24,7 +24,7 @@ export default function StopPaymentScreen({ navigation }: Props) {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
toast('Payment Stopped', 'success', `Cheque #${chequeNumber} has been blocked.`);
toast('Payment stopped', 'success', `We've stopped payment on cheque #${chequeNumber}.`);
navigation.goBack();
}, 1500);
};
+4
View File
@@ -1,6 +1,8 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from '../modules/home/screens/HomeScreen';
import GlobalSearchScreen from '../modules/search/screens/GlobalSearchScreen';
import NotificationsScreen from '../modules/notifications/screens/NotificationsScreen';
import MiniStatementScreen from '../modules/home/screens/MiniStatementScreen';
import AccountDetailsScreen from '../modules/home/screens/AccountDetailsScreen';
import ReKycScreen from '../modules/home/screens/ReKycScreen';
@@ -14,6 +16,8 @@ export const HomeNavigator = () => {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="HomeMain" component={HomeScreen} />
<Stack.Screen name="GlobalSearch" component={GlobalSearchScreen} />
<Stack.Screen name="Notifications" component={NotificationsScreen} />
<Stack.Screen name="MiniStatement" component={MiniStatementScreen} />
<Stack.Screen name="AccountDetails" component={AccountDetailsScreen} />
<Stack.Screen name="ReKyc" component={ReKycScreen} />
-2
View File
@@ -20,8 +20,6 @@ export const RootNavigator = () => {
const renderAppStack = () => {
switch (role) {
case 'Employee':
return <EmployeeNavigator />;
case 'Agent':
return <AgentNavigator />;
case 'User':
+2
View File
@@ -22,6 +22,8 @@ export type LoanStackParamList = {
export type HomeStackParamList = {
HomeMain: undefined;
GlobalSearch: undefined;
Notifications: undefined;
MiniStatement: { accountId: string };
AccountDetails: { accountId: string };
ReKyc: { accountId: string };
+127 -6
View File
@@ -1,9 +1,130 @@
import { PermissionsAndroid, Platform } from 'react-native';
import notifee, {
AndroidImportance,
AuthorizationStatus,
TriggerType,
type TimestampTrigger,
} from '@notifee/react-native';
export const registerForPushNotifications = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) {
return PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
}
import type { NotificationCategory } from '../../types/app';
return 'granted';
const CHANNEL_ID = 'bankcbs-general';
export type DummyNotificationContent = {
category: NotificationCategory;
title: string;
body: string;
};
const DUMMY_POOL: DummyNotificationContent[] = [
{
category: 'transaction',
title: 'Money received',
body: '₹4,200 was credited to your Savings account from Rohan Mehta.',
},
{
category: 'transaction',
title: 'Payment successful',
body: 'Your payment of ₹1,150 for a mobile recharge went through.',
},
{
category: 'security',
title: 'New sign-in detected',
body: 'A new sign-in was detected on your account. Tap to review.',
},
{
category: 'offer',
title: 'Reward unlocked',
body: 'You just earned 250 reward points on your last card spend.',
},
{
category: 'reminder',
title: 'Bill reminder',
body: 'Your electricity bill of ₹2,340 is due in 3 days.',
},
];
const DEFAULT_CONTENT: DummyNotificationContent = DUMMY_POOL[0] ?? {
category: 'system',
title: 'New notification',
body: 'You have a new update.',
};
export const getDummyNotificationContent = (): DummyNotificationContent => {
const index = Math.floor(Math.random() * DUMMY_POOL.length);
return DUMMY_POOL[index] ?? DEFAULT_CONTENT;
};
let channelReady = false;
const ensureChannel = async () => {
if (!channelReady) {
await notifee.createChannel({
id: CHANNEL_ID,
name: 'General alerts',
importance: AndroidImportance.HIGH,
});
channelReady = true;
}
return CHANNEL_ID;
};
/** Request OS notification permission and make sure the Android channel exists. */
export const registerForPushNotifications = async () => {
const settings = await notifee.requestPermission();
await ensureChannel();
const granted =
settings.authorizationStatus === AuthorizationStatus.AUTHORIZED ||
settings.authorizationStatus === AuthorizationStatus.PROVISIONAL;
return granted ? 'granted' : 'denied';
};
/**
* Show a notification immediately. While the app is in the foreground this
* appears as a heads-up popup (HIGH importance channel); it also lands in the
* system tray.
*/
export const displayLocalNotification = async (content: DummyNotificationContent) => {
const channelId = await ensureChannel();
await notifee.displayNotification({
title: content.title,
body: content.body,
android: {
channelId,
smallIcon: 'ic_launcher',
importance: AndroidImportance.HIGH,
pressAction: { id: 'default' },
},
});
};
/**
* Schedule a notification to fire after `seconds`. This is how we demo a
* background notification: trigger it, then minimise the app and it arrives in
* the system tray even though the app is no longer in the foreground.
*/
export const scheduleLocalNotification = async (
content: DummyNotificationContent,
seconds = 5,
) => {
const channelId = await ensureChannel();
const trigger: TimestampTrigger = {
type: TriggerType.TIMESTAMP,
timestamp: Date.now() + seconds * 1000,
};
await notifee.createTriggerNotification(
{
title: content.title,
body: content.body,
android: {
channelId,
smallIcon: 'ic_launcher',
importance: AndroidImportance.HIGH,
pressAction: { id: 'default' },
},
},
trigger,
);
};
+18 -1
View File
@@ -27,7 +27,7 @@ export type CardDeliveryStage =
| 'ready-for-pickup'
| 'delivered';
export type LoginRole = 'User' | 'Employee' | 'Agent';
export type LoginRole = 'User' | 'Agent';
export interface AuthTokens {
accessToken: string;
@@ -57,6 +57,23 @@ export interface UserProfile {
notifications: NotificationPreference;
}
export type NotificationCategory =
| 'transaction'
| 'security'
| 'offer'
| 'reminder'
| 'system';
export interface AppNotification {
id: string;
category: NotificationCategory;
title: string;
body: string;
/** ISO timestamp of when the notification was raised. */
createdAt: string;
read: boolean;
}
export interface AccountSummary {
id: string;
type: AccountType;