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/ android/app/.cxx/
**/ComponentDescriptors.cpp.o **/ComponentDescriptors.cpp.o
**/react_codegen_rngesturehandler_codegen.dir/ **/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.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_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 <application
android:name=".MainApplication" android:name=".MainApplication"
+39 -8
View File
@@ -10,12 +10,38 @@
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m # 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. # Run independent modules/projects concurrently instead of one after another.
# This option should only be used with decoupled projects. More details, visit # On a multi-module build this compiles modules in parallel, cutting wall-clock build time.
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects org.gradle.parallel=true
# 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 # 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 # 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 android.useAndroidX=true
# Use this property to specify which architecture you want to build. # Use this property to specify which architecture you want to build.
# You can also override it from the CLI using # Mobile-only: build for physical Android devices (ARM) and skip emulator ABIs (x86, x86_64).
# ./gradlew <task> -PreactNativeArchitectures=x86_64 # Dropping the two emulator architectures roughly halves native build time and APK size.
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 # 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. # Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in # 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. # 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. # Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=false edgeToEdgeEnabled=false
+8
View File
@@ -3,9 +3,17 @@
*/ */
import { AppRegistry, Text, TextInput } from 'react-native'; import { AppRegistry, Text, TextInput } from 'react-native';
import notifee from '@notifee/react-native';
import App from './App'; import App from './App';
import { name as appName } from './app.json'; 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 = { const customTextProps = {
style: { style: {
fontFamily: 'Nunito-Regular', fontFamily: 'Nunito-Regular',
+10
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@gorhom/bottom-sheet": "^5.2.14", "@gorhom/bottom-sheet": "^5.2.14",
"@maplibre/maplibre-react-native": "^10.4.2", "@maplibre/maplibre-react-native": "^10.4.2",
"@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/geolocation": "^3.4.0", "@react-native-community/geolocation": "^3.4.0",
"@react-native-vector-icons/ionicons": "^13.0.0", "@react-native-vector-icons/ionicons": "^13.0.0",
@@ -2829,6 +2830,15 @@
"node": ">= 8" "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": { "node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", "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": { "dependencies": {
"@gorhom/bottom-sheet": "^5.2.14", "@gorhom/bottom-sheet": "^5.2.14",
"@maplibre/maplibre-react-native": "^10.4.2", "@maplibre/maplibre-react-native": "^10.4.2",
"@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/geolocation": "^3.4.0", "@react-native-community/geolocation": "^3.4.0",
"@react-native-vector-icons/ionicons": "^13.0.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 { schemesReducer } from '../modules/schemes/store/schemesSlice';
import { schemesApi } from '../modules/schemes/services/schemesApi'; import { schemesApi } from '../modules/schemes/services/schemesApi';
import { beneficiariesReducer } from '../modules/payments/store/beneficiariesSlice'; import { beneficiariesReducer } from '../modules/payments/store/beneficiariesSlice';
import { notificationsReducer } from '../modules/notifications/store/notificationsSlice';
const appReducer = combineReducers({ const appReducer = combineReducers({
auth: authReducer, auth: authReducer,
@@ -31,6 +32,7 @@ const appReducer = combineReducers({
ui: uiReducer, ui: uiReducer,
schemes: schemesReducer, schemes: schemesReducer,
beneficiaries: beneficiariesReducer, beneficiaries: beneficiariesReducer,
notifications: notificationsReducer,
agent: agentReducer, agent: agentReducer,
[baseApi.reducerPath]: baseApi.reducer, [baseApi.reducerPath]: baseApi.reducer,
[schemesApi.reducerPath]: schemesApi.reducer, [schemesApi.reducerPath]: schemesApi.reducer,
+52
View File
@@ -1,6 +1,7 @@
import type { import type {
AccountSummary, AccountSummary,
ActiveLoan, ActiveLoan,
AppNotification,
BankCard, BankCard,
Beneficiary, Beneficiary,
Branch, 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[] = [ export const demoAccounts: AccountSummary[] = [
{ {
id: 'acc_primary', id: 'acc_primary',
+1 -1
View File
@@ -65,7 +65,7 @@ export const useAppBootstrap = () => {
useEffect(() => { useEffect(() => {
const unregister = registerUnauthorizedHandler(() => { const unregister = registerUnauthorizedHandler(() => {
dispatch(logoutSession()); 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; return unregister;
@@ -11,7 +11,7 @@ interface LoginRoleTabsProps {
onRoleChange: (role: LoginRole) => void; onRoleChange: (role: LoginRole) => void;
} }
const ROLES: LoginRole[] = ['User', 'Employee', 'Agent']; const ROLES: LoginRole[] = ['User', 'Agent'];
const ActiveTabBackground = () => ( const ActiveTabBackground = () => (
<Animated.View <Animated.View
+6 -6
View File
@@ -36,12 +36,12 @@ export default function LoginScreen(props: Readonly<Props>) {
const handleForgotPassword = () => { const handleForgotPassword = () => {
haptics.selection(); haptics.selection();
toast('Redirecting to secure reset channel...', 'info'); toast('Resetting your password', 'info', 'We are taking you to reset your password.');
}; };
const handleSignUp = () => { const handleSignUp = () => {
haptics.selection(); haptics.selection();
toast('Initializing onboarding sequence...', 'info'); toast('Creating your account', 'info', 'We are taking you to open a new account.');
}; };
const handleContinue = async () => { const handleContinue = async () => {
@@ -55,11 +55,11 @@ export default function LoginScreen(props: Readonly<Props>) {
await secureStorage.saveSession(tokens); await secureStorage.saveSession(tokens);
dispatch(authActions.authenticate(tokens)); dispatch(authActions.authenticate(tokens));
haptics.success(); haptics.success();
toast('Neural bridge established', 'success'); toast('Signed in', 'success', 'Welcome back to your account.');
return; return;
} catch { } catch {
haptics.error(); 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(); const success = await promptBiometric();
if (!success) { if (!success) {
toast('Biometric scan aborted', 'info'); toast('Biometric sign-in cancelled', 'info', 'You can try again or sign in with your password.');
return; return;
} }
@@ -82,7 +82,7 @@ export default function LoginScreen(props: Readonly<Props>) {
await secureStorage.saveSession(tokens); await secureStorage.saveSession(tokens);
dispatch(authActions.authenticate(tokens)); dispatch(authActions.authenticate(tokens));
haptics.success(); haptics.success();
toast('Synapse link verified', 'success'); toast('Signed in', 'success', 'Your identity was confirmed. Welcome back.');
}; };
return ( return (
@@ -91,31 +91,31 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
toast( toast(
'Income too low', 'Income too low',
'error', '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; return;
} }
if (!employerName.trim()) { 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; return;
} }
if (!city.trim()) { 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; return;
} }
if (!selectedRepaymentAccount) { 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; return;
} }
if (!consentAccepted) { if (!consentAccepted) {
toast( toast(
'Consent required', 'Consent needed',
'error', 'error',
'Accept the bureau and KYC consent to submit your application.', 'Please accept the credit bureau and KYC consent to submit your application.',
); );
return; return;
} }
+18 -18
View File
@@ -200,8 +200,8 @@ export default function CardDetailsScreen({ route }: Props) {
nextStatus === 'blocked' ? 'Card blocked' : 'Card active', nextStatus === 'blocked' ? 'Card blocked' : 'Card active',
nextStatus === 'blocked' ? 'error' : 'success', nextStatus === 'blocked' ? 'error' : 'success',
nextStatus === 'blocked' nextStatus === 'blocked'
? 'All new transactions are disabled.' ? 'New transactions on this card are now stopped.'
: 'Transactions are enabled again.', : 'You can use this card again.',
); );
}; };
@@ -217,8 +217,8 @@ export default function CardDetailsScreen({ route }: Props) {
card.controls.frozen ? 'Card unfrozen' : 'Card frozen', card.controls.frozen ? 'Card unfrozen' : 'Card frozen',
'success', 'success',
card.controls.frozen card.controls.frozen
? 'The card is ready for use again.' ? 'Your card is ready to use again.'
: 'Temporary freeze applied successfully.', : 'Transactions on this card are paused for now.',
); );
}; };
@@ -227,16 +227,16 @@ export default function CardDetailsScreen({ route }: Props) {
if (!authenticated) { if (!authenticated) {
toast( toast(
'Authentication required', 'Verification needed',
'info', 'info',
'Use biometrics to reveal the full number and CVV.', 'Confirm with your fingerprint or face to view the full number and CVV.',
); );
return; return;
} }
setSensitiveVisible((currentValue) => !currentValue); setSensitiveVisible((currentValue) => !currentValue);
toast( toast(
sensitiveVisible ? 'Sensitive details hidden' : 'Sensitive details unlocked', sensitiveVisible ? 'Card details hidden' : 'Card details shown',
'success', 'success',
); );
}; };
@@ -245,11 +245,11 @@ export default function CardDetailsScreen({ route }: Props) {
const authenticated = await promptBiometric(); const authenticated = await promptBiometric();
if (!authenticated) { 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; return;
} }
toast(actionLabel, 'success', `${actionLabel} journey started securely.`); toast(actionLabel, 'success', `You can now ${actionLabel.toLowerCase()} securely.`);
}; };
const handleGenerateVirtualCard = () => { const handleGenerateVirtualCard = () => {
@@ -259,18 +259,18 @@ export default function CardDetailsScreen({ route }: Props) {
virtualCard: createVirtualCardPayload(card.id, virtualLimitDraft), 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 = () => { const handleDeleteVirtualCard = () => {
dispatch(cardsActions.deleteVirtualCard({ cardId: card.id })); dispatch(cardsActions.deleteVirtualCard({ cardId: card.id }));
setVirtualLimitDraft(10000); 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 = () => { const handleRedeemRewards = () => {
if (card.rewards.points <= 0) { 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; return;
} }
@@ -279,7 +279,7 @@ export default function CardDetailsScreen({ route }: Props) {
toast( toast(
'Rewards redeemed', 'Rewards redeemed',
'success', '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(); const nextName = renameDraft.trim();
if (nextName.length < 3) { 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; return;
} }
dispatch(cardsActions.renameCard({ cardId: card.id, name: nextName })); 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 = () => { const handleSetDefaultCard = () => {
dispatch(cardsActions.setDefaultCard({ cardId: card.id })); 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) => { 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) => { 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 = const budgetProgress =
@@ -1,7 +1,6 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { import {
Pressable, Pressable,
ScrollView,
StyleSheet, StyleSheet,
Text, Text,
View, View,
@@ -16,6 +15,7 @@ import { useToast } from '../../../hooks/useToast';
import type { CardsStackParamList } from '../../../navigation/types'; import type { CardsStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme'; import { useTheme } from '../../../theme';
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters'; import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { CardCarousel } from '../components/CardCarousel'; import { CardCarousel } from '../components/CardCarousel';
import { CardDeliveryStatus } from '../components/CardDeliveryStatus'; import { CardDeliveryStatus } from '../components/CardDeliveryStatus';
import { CardsSkeleton } from '../components/CardsSkeleton'; import { CardsSkeleton } from '../components/CardsSkeleton';
@@ -260,8 +260,8 @@ export default function CardsOverviewScreen({ navigation }: Props) {
nextStatus === 'blocked' ? 'Card blocked' : 'Card unblocked', nextStatus === 'blocked' ? 'Card blocked' : 'Card unblocked',
nextStatus === 'blocked' ? 'error' : 'success', nextStatus === 'blocked' ? 'error' : 'success',
nextStatus === 'blocked' nextStatus === 'blocked'
? 'This card is now blocked for all new transactions.' ? 'Your card is now blocked for all new transactions.'
: 'This card is active again.', : 'Your card is active again.',
); );
}; };
@@ -296,14 +296,18 @@ export default function CardsOverviewScreen({ navigation }: Props) {
<ScreenShell <ScreenShell
title="Cards" title="Cards"
subtitle="Premium controls, insights, and secure card tools" subtitle="Premium controls, insights, and secure card tools"
scrollable={false} contentContainerStyle={styles.scrollContent}
> fixedFooter={
<View style={styles.flex}> <Pressable
<ScrollView onPress={handleRequestDebitCard}
showsVerticalScrollIndicator={false} style={[styles.fab, { backgroundColor: theme.colors.primary }]}
contentContainerStyle={styles.scrollContent}
> >
<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> <View>
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}> <Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
Selected wallet card Selected wallet card
@@ -558,32 +562,15 @@ export default function CardsOverviewScreen({ navigation }: Props) {
</View> </View>
</Card> </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> </ScreenShell>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
flex: {
flex: 1,
},
scrollContent: { scrollContent: {
gap: 18, gap: 18,
paddingBottom: 120, // Clear the absolute tab bar + the floating "Request Card" button.
paddingBottom: TAB_BAR_HEIGHT + 120,
}, },
emptyStateWrap: { emptyStateWrap: {
gap: 20, gap: 20,
@@ -805,13 +792,10 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
fontWeight: '800', fontWeight: '800',
}, },
footerSpace: {
height: 24,
},
fab: { fab: {
position: 'absolute', position: 'absolute',
right: 4, right: 4,
bottom: 22, bottom: TAB_BAR_HEIGHT + 20,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
@@ -829,7 +813,4 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '800', fontWeight: '800',
}, },
bottomInset: {
height: 60,
},
}); });
@@ -96,17 +96,17 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
const handleSubmit = () => { const handleSubmit = () => {
if (!selectedAccount) { 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; return;
} }
if (!nameOnCard.trim()) { 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; return;
} }
if (deliveryMode === 'home' && !deliveryAddress.trim()) { 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; return;
} }
@@ -84,7 +84,7 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
'success', 'success',
deposit.status === 'matured' deposit.status === 'matured'
? 'The maturity amount has been credited to your linked account.' ? '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 { } catch {
toast( toast(
@@ -99,7 +99,7 @@ export default function OpenFixedDepositScreen({ navigation }: Props) {
setErrors(validation); setErrors(validation);
if (Object.keys(validation).length) { 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; return;
} }
@@ -110,7 +110,7 @@ export default function OpenFixedDepositScreen({ navigation }: Props) {
interestPayoutType: payoutType, interestPayoutType: payoutType,
}).unwrap(); }).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', { navigation.replace('DepositDetails', {
depositId: deposit.id, depositId: deposit.id,
kind: deposit.kind, kind: deposit.kind,
@@ -88,7 +88,7 @@ export default function OpenRecurringDepositScreen({ navigation }: Props) {
setErrors(validation); setErrors(validation);
if (Object.keys(validation).length) { 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; return;
} }
@@ -98,13 +98,13 @@ export default function OpenRecurringDepositScreen({ navigation }: Props) {
tenureMonths, tenureMonths,
}).unwrap(); }).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', { navigation.replace('DepositDetails', {
depositId: deposit.id, depositId: deposit.id,
kind: deposit.kind, kind: deposit.kind,
}); });
} catch { } 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 { ServiceGridItem } from '../components/ServiceGridItem';
import { PromoBanner } from '../components/PromoBanner'; import { PromoBanner } from '../components/PromoBanner';
import { HorizontalCategories } from '../components/HorizontalCategories'; import { HorizontalCategories } from '../components/HorizontalCategories';
import { selectUnreadNotificationCount } from '../../notifications/store/notificationsSlice';
const { width: SCREEN_WIDTH } = Dimensions.get('window'); const { width: SCREEN_WIDTH } = Dimensions.get('window');
const SPACING = 16; const SPACING = 16;
@@ -27,6 +28,7 @@ export default function HomeScreen({ navigation }: Props) {
const theme = useTheme(); const theme = useTheme();
const accounts = useAppSelector((state) => state.account.accounts); const accounts = useAppSelector((state) => state.account.accounts);
const profile = useAppSelector((state) => state.user.profile); const profile = useAppSelector((state) => state.user.profile);
const unreadNotifications = useAppSelector(selectUnreadNotificationCount);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const scrollX = useRef(new Animated.Value(0)).current; const scrollX = useRef(new Animated.Value(0)).current;
const account = accounts[activeIndex] || accounts[0]; const account = accounts[activeIndex] || accounts[0];
@@ -97,10 +99,23 @@ export default function HomeScreen({ navigation }: Props) {
</View> </View>
</View> </View>
<View style={styles.headerActions}> <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} /> <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>
<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} /> <Ionicons name="search-outline" size={22} color={theme.colors.textPrimary} />
</Pressable> </Pressable>
</View> </View>
@@ -276,6 +291,23 @@ const styles = StyleSheet.create({
shadowRadius: 5, shadowRadius: 5,
elevation: 2, 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: { section: {
paddingHorizontal: 6, paddingHorizontal: 6,
marginBottom: 24, marginBottom: 24,
@@ -100,7 +100,7 @@ export default function BuyInsuranceScreen({ navigation, route }: Props) {
return; 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 () => { const handleConfirmPurchase = async () => {
@@ -116,7 +116,7 @@ export default function InsuranceClaimsScreen({ route }: Props) {
return; 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 () => { const handleRequestOtp = async () => {
@@ -136,7 +136,7 @@ export default function InsuranceClaimsScreen({ route }: Props) {
return; 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 () => { const handleSubmitClaim = async () => {
@@ -60,9 +60,9 @@ export default function InsuranceDocumentsScreen(_props: Props) {
const result = await uploadDocument(sample); const result = await uploadDocument(sample);
if ('data' in result) { 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 { } 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 <InsuranceDocumentItem
document={item} document={item}
onView={() => 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={() => onReupload={() =>
void uploadDocument({ void uploadDocument({
@@ -127,7 +127,7 @@ export default function InsurancePolicyDetailsScreen({ navigation, route }: Prop
toast( toast(
'Policy download ready', 'Policy download ready',
'success', '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( toast(
enabled ? 'Auto-renew enabled' : 'Auto-renew disabled', enabled ? 'Auto-renew enabled' : 'Auto-renew disabled',
'success', 'success',
`${due.planName} has been updated.`, `Your settings for ${due.planName} have been updated.`,
); );
} else { } 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; 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 () => { 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 { ScreenShell, Input, Button, Card, AppModal } from '../../../components';
import { useAddBeneficiaryMutation } from '../services/paymentsApi'; import { useAddBeneficiaryMutation } from '../services/paymentsApi';
import { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types'; import type { PaymentsStackParamList } from '../../../navigation/types';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { beneficiariesActions } from '../store/beneficiariesSlice'; import { beneficiariesActions } from '../store/beneficiariesSlice';
@@ -12,7 +11,6 @@ import { beneficiariesActions } from '../store/beneficiariesSlice';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'AddBeneficiary'>; type Props = NativeStackScreenProps<PaymentsStackParamList, 'AddBeneficiary'>;
export default function AddBeneficiaryScreen({ navigation }: Props) { export default function AddBeneficiaryScreen({ navigation }: Props) {
const theme = useTheme();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [addBen, { isLoading }] = useAddBeneficiaryMutation(); const [addBen, { isLoading }] = useAddBeneficiaryMutation();
@@ -26,36 +24,53 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
const [otpModalVisible, setOtpModalVisible] = useState(false); const [otpModalVisible, setOtpModalVisible] = useState(false);
const [otp, setOtp] = useState(''); const [otp, setOtp] = useState('');
const [otpError, setOtpError] = useState<string | undefined>(undefined);
const isValid = const isValid =
form.name.length > 2 && form.name.trim().length > 2 &&
form.accountNumber.length > 6 && form.accountNumber.length > 6 &&
form.accountNumber === form.confirmAccountNumber && form.accountNumber === form.confirmAccountNumber &&
form.ifsc.length === 11; form.ifsc.length === 11;
const handleSubmit = () => { const handleSubmit = () => {
if (!isValid) return; if (!isValid) return;
setOtp('');
setOtpError(undefined);
setOtpModalVisible(true); setOtpModalVisible(true);
}; };
const closeOtpModal = () => {
setOtpModalVisible(false);
setOtp('');
setOtpError(undefined);
};
const confirmOTPAndSave = async () => { 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); setOtpModalVisible(false);
try { try {
const nickname = form.nickname.trim() || undefined;
const newBeneficiaryObj = { const newBeneficiaryObj = {
name: form.name, name: form.name.trim(),
bankName: form.ifsc.startsWith('HDFC') ? 'HDFC Bank' : 'State Bank of India', // mock logic bankName: form.ifsc.toUpperCase().startsWith('HDFC') ? 'HDFC Bank' : 'State Bank of India', // mock logic
accountMasked: form.accountNumber.slice(-4).padStart(8, 'X'), accountMasked: form.accountNumber.slice(-4).padStart(8, 'X'),
nickname: form.nickname, nickname,
}; };
const payload = await addBen(newBeneficiaryObj).unwrap(); 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(); navigation.goBack();
} catch (e) { } catch (e) {
console.warn("Failed to add beneficiary", 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" label="IFSC Code"
placeholder="e.g. SBIN0001234" placeholder="e.g. SBIN0001234"
autoCapitalize="characters" autoCapitalize="characters"
maxLength={11}
value={form.ifsc} value={form.ifsc}
onChangeText={(v) => setForm({ ...form, ifsc: v })} onChangeText={(v) => setForm({ ...form, ifsc: v })}
error={form.ifsc.length > 0 && form.ifsc.length !== 11 ? 'IFSC code must be 11 characters' : undefined}
/> />
<Input <Input
label="Nickname (Optional)" label="Nickname (Optional)"
@@ -109,7 +126,7 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
<AppModal <AppModal
visible={otpModalVisible} visible={otpModalVisible}
onClose={() => setOtpModalVisible(false)} onClose={closeOtpModal}
title="Bank Authentication" title="Bank Authentication"
> >
<View style={{ gap: 16 }}> <View style={{ gap: 16 }}>
@@ -119,7 +136,11 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
keyboardType="number-pad" keyboardType="number-pad"
maxLength={6} maxLength={6}
value={otp} value={otp}
onChangeText={setOtp} onChangeText={(v) => {
setOtp(v);
if (otpError) setOtpError(undefined);
}}
error={otpError}
/> />
<Button <Button
title="Confirm Addition" title="Confirm Addition"
@@ -4,7 +4,8 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons'; import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, Card, Input } from '../../../components'; 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 { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants'; import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import type { PaymentsStackParamList } from '../../../navigation/types'; import type { PaymentsStackParamList } from '../../../navigation/types';
@@ -13,7 +14,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'BeneficiariesList'>
export default function BeneficiariesListScreen({ navigation }: Props) { export default function BeneficiariesListScreen({ navigation }: Props) {
const theme = useTheme(); const theme = useTheme();
const { data: beneficiaries = [] } = useGetBeneficiariesQuery(); const beneficiaries = useAppSelector(selectBeneficiaries);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const filtered = beneficiaries.filter(b => const filtered = beneficiaries.filter(b =>
@@ -1,10 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { StyleSheet, Text, View, ScrollView } from 'react-native'; import { StyleSheet, Text, View, ScrollView } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack'; 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 { 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 { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types'; import type { PaymentsStackParamList } from '../../../navigation/types';
import { accountActions } from '../../home/store/accountSlice'; import { accountActions } from '../../home/store/accountSlice';
@@ -16,8 +17,7 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { beneficiaryId, transferType, amount, remarks } = route.params; const { beneficiaryId, transferType, amount, remarks } = route.params;
const { data: beneficiaries } = useGetBeneficiariesQuery(); const beneficiary = useSelector(selectBeneficiaryById(beneficiaryId));
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
const [submitTransfer, { isLoading }] = useSubmitBankTransferMutation(); const [submitTransfer, { isLoading }] = useSubmitBankTransferMutation();
const [otpModalVisible, setOtpModalVisible] = useState(false); const [otpModalVisible, setOtpModalVisible] = useState(false);
@@ -5,7 +5,7 @@ import { useDispatch, useSelector } from 'react-redux';
import Ionicons from '@react-native-vector-icons/ionicons'; import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, Input, Button, Card } from '../../../components'; import { ScreenShell, Input, Button, Card } from '../../../components';
import { useGetBeneficiariesQuery } from '../services/paymentsApi'; import { selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme'; import { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types'; import type { PaymentsStackParamList } from '../../../navigation/types';
import type { RootState } from '../../../app/store'; import type { RootState } from '../../../app/store';
@@ -18,8 +18,7 @@ export function PaymentFormScreen({ navigation, route }: Props) {
const theme = useTheme(); const theme = useTheme();
const beneficiaryId = route.params?.beneficiaryId; const beneficiaryId = route.params?.beneficiaryId;
const transferType = route.params?.transferType; const transferType = route.params?.transferType;
const { data: beneficiaries } = useGetBeneficiariesQuery(); const beneficiary = useSelector(selectBeneficiaryById(beneficiaryId));
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
@@ -4,7 +4,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Card, ScreenShell } from '../../../components'; import { Card, ScreenShell } from '../../../components';
import { useGetBeneficiariesQuery } from '../services/paymentsApi'; import { selectBeneficiaries } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme'; import { useTheme } from '../../../theme';
import { useAppSelector } from '../../../app/hooks'; import { useAppSelector } from '../../../app/hooks';
import type { PaymentsStackParamList } from '../../../navigation/types'; import type { PaymentsStackParamList } from '../../../navigation/types';
@@ -16,7 +16,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentsHub'>;
export default function PaymentsHomeScreen({ navigation }: Props) { export default function PaymentsHomeScreen({ navigation }: Props) {
const theme = useTheme(); const theme = useTheme();
const transactions = useAppSelector(state => state.account.transactions); const transactions = useAppSelector(state => state.account.transactions);
const { data: beneficiaries = [] } = useGetBeneficiariesQuery(); const beneficiaries = useAppSelector(selectBeneficiaries);
// Simulated stats // Simulated stats
const dailyLimit = 500000; const dailyLimit = 500000;
@@ -4,7 +4,8 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons'; import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, Card } from '../../../components'; 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 { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types'; import type { PaymentsStackParamList } from '../../../navigation/types';
@@ -13,8 +14,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransferTypeSelecti
export default function TransferTypeScreen({ navigation, route }: Props) { export default function TransferTypeScreen({ navigation, route }: Props) {
const theme = useTheme(); const theme = useTheme();
const { beneficiaryId } = route.params; const { beneficiaryId } = route.params;
const { data: beneficiaries } = useGetBeneficiariesQuery(); const beneficiary = useAppSelector(selectBeneficiaryById(beneficiaryId));
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
const handleSelect = (type: 'IMPS' | 'NEFT' | 'RTGS') => { const handleSelect = (type: 'IMPS' | 'NEFT' | 'RTGS') => {
navigation.navigate('PaymentForm', { beneficiaryId, transferType: type }); navigation.navigate('PaymentForm', { beneficiaryId, transferType: type });
@@ -1,5 +1,6 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { Beneficiary } from '../../../types/app'; import type { Beneficiary } from '../../../types/app';
import type { RootState } from '../../../app/store';
import { demoBeneficiaries } from '../../../constants/mockData'; import { demoBeneficiaries } from '../../../constants/mockData';
interface BeneficiariesState { interface BeneficiariesState {
@@ -34,3 +35,9 @@ const beneficiariesSlice = createSlice({
export const beneficiariesActions = beneficiariesSlice.actions; export const beneficiariesActions = beneficiariesSlice.actions;
export const beneficiariesReducer = beneficiariesSlice.reducer; 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) => { 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 ( return (
@@ -20,7 +20,7 @@ export default function ChequeBookRequestScreen({ navigation }: Props) {
setIsLoading(true); setIsLoading(true);
setTimeout(() => { setTimeout(() => {
setIsLoading(false); 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(); navigation.goBack();
}, 1500); }, 1500);
}; };
@@ -18,13 +18,13 @@ export default function Form15G15HScreen({ navigation }: Props) {
const handleSubmit = () => { const handleSubmit = () => {
if (!panNumber || !estimatedIncome) { 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; return;
} }
setIsLoading(true); setIsLoading(true);
setTimeout(() => { setTimeout(() => {
setIsLoading(false); 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(); navigation.goBack();
}, 1500); }, 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={{ fontWeight: '700', color: theme.colors.textPrimary }}>Form 15G - FY 24-25</Text>
<Text style={{ fontSize: 12, color: theme.colors.textSecondary }}>Submitted April 2025</Text> <Text style={{ fontSize: 12, color: theme.colors.textSecondary }}>Submitted April 2025</Text>
</View> </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> </Card>
</View> </View>
</ScreenShell> </ScreenShell>
@@ -20,13 +20,13 @@ export default function PositivePayScreen({ navigation }: Props) {
const handleSubmit = () => { const handleSubmit = () => {
if (!chequeNumber || !amount || !payee) { 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; return;
} }
setIsLoading(true); setIsLoading(true);
setTimeout(() => { setTimeout(() => {
setIsLoading(false); 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(); navigation.goBack();
}, 1500); }, 1500);
}; };
@@ -80,9 +80,9 @@ export default function ServicesHubScreen({ navigation }: Props) {
iconColor: '#0EA5E9', iconColor: '#0EA5E9',
onPress: () => onPress: () =>
toast( toast(
'Support chat screen pending', 'Support chat coming soon',
'info', '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', icon: 'help-circle-outline',
iconColor: '#8B5CF6', iconColor: '#8B5CF6',
onPress: () => onPress: () =>
toast('Help center pending', 'info', 'Coming soon.'), toast('Help center coming soon', 'info', 'This feature will be available shortly.'),
}, },
{ {
id: 'cyber_security', id: 'cyber_security',
@@ -17,13 +17,13 @@ export default function SpecialInstructionsScreen({ navigation }: Props) {
const handleSubmit = () => { const handleSubmit = () => {
if (!title || !description) { 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; return;
} }
setIsLoading(true); setIsLoading(true);
setTimeout(() => { setTimeout(() => {
setIsLoading(false); 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(); navigation.goBack();
}, 1500); }, 1500);
}; };
@@ -24,7 +24,7 @@ export default function StopPaymentScreen({ navigation }: Props) {
setIsLoading(true); setIsLoading(true);
setTimeout(() => { setTimeout(() => {
setIsLoading(false); setIsLoading(false);
toast('Payment Stopped', 'success', `Cheque #${chequeNumber} has been blocked.`); toast('Payment stopped', 'success', `We've stopped payment on cheque #${chequeNumber}.`);
navigation.goBack(); navigation.goBack();
}, 1500); }, 1500);
}; };
+4
View File
@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from '../modules/home/screens/HomeScreen'; 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 MiniStatementScreen from '../modules/home/screens/MiniStatementScreen';
import AccountDetailsScreen from '../modules/home/screens/AccountDetailsScreen'; import AccountDetailsScreen from '../modules/home/screens/AccountDetailsScreen';
import ReKycScreen from '../modules/home/screens/ReKycScreen'; import ReKycScreen from '../modules/home/screens/ReKycScreen';
@@ -14,6 +16,8 @@ export const HomeNavigator = () => {
return ( return (
<Stack.Navigator screenOptions={{ headerShown: false }}> <Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="HomeMain" component={HomeScreen} /> <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="MiniStatement" component={MiniStatementScreen} />
<Stack.Screen name="AccountDetails" component={AccountDetailsScreen} /> <Stack.Screen name="AccountDetails" component={AccountDetailsScreen} />
<Stack.Screen name="ReKyc" component={ReKycScreen} /> <Stack.Screen name="ReKyc" component={ReKycScreen} />
-2
View File
@@ -20,8 +20,6 @@ export const RootNavigator = () => {
const renderAppStack = () => { const renderAppStack = () => {
switch (role) { switch (role) {
case 'Employee':
return <EmployeeNavigator />;
case 'Agent': case 'Agent':
return <AgentNavigator />; return <AgentNavigator />;
case 'User': case 'User':
+2
View File
@@ -22,6 +22,8 @@ export type LoanStackParamList = {
export type HomeStackParamList = { export type HomeStackParamList = {
HomeMain: undefined; HomeMain: undefined;
GlobalSearch: undefined;
Notifications: undefined;
MiniStatement: { accountId: string }; MiniStatement: { accountId: string };
AccountDetails: { accountId: string }; AccountDetails: { accountId: string };
ReKyc: { 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 () => { import type { NotificationCategory } from '../../types/app';
if (Platform.OS === 'android' && Platform.Version >= 33) {
return PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
}
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' | 'ready-for-pickup'
| 'delivered'; | 'delivered';
export type LoginRole = 'User' | 'Employee' | 'Agent'; export type LoginRole = 'User' | 'Agent';
export interface AuthTokens { export interface AuthTokens {
accessToken: string; accessToken: string;
@@ -57,6 +57,23 @@ export interface UserProfile {
notifications: NotificationPreference; 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 { export interface AccountSummary {
id: string; id: string;
type: AccountType; type: AccountType;