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:
co-authored by
Claude Opus 4.8
parent
d7907e1d64
commit
58af541536
@@ -17,3 +17,6 @@ coverage
|
||||
android/app/.cxx/
|
||||
**/ComponentDescriptors.cpp.o
|
||||
**/react_codegen_rngesturehandler_codegen.dir/
|
||||
|
||||
# Local Claude Code session data (never push)
|
||||
.claude/
|
||||
Vendored
+2
-1
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<!-- Required for showing notifications on Android 13+ (Notifee). -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
|
||||
@@ -10,12 +10,38 @@
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
#
|
||||
# Build-time JVM tuning (each flag explained):
|
||||
# -Xmx2048m : max heap for the Gradle daemon (kept as-is).
|
||||
# -XX:MaxMetaspaceSize=512m : cap on class-metadata space (kept as-is).
|
||||
# -XX:+UseG1GC : use the G1 garbage collector - shorter, more
|
||||
# predictable GC pauses than the default on large
|
||||
# heaps, so build threads stall less during GC.
|
||||
# -XX:+ParallelRefProcEnabled : process GC reference objects on multiple threads,
|
||||
# shortening pauses on builds that allocate heavily.
|
||||
# -XX:+UnlockDiagnosticVMOptions : unlock diagnostic VM options so finer GC tuning
|
||||
# flags can be applied when needed.
|
||||
# -XX:G1ReservePercent=5 : reserve 5% of heap as headroom to reduce costly
|
||||
# promotion failures / full GCs during the build.
|
||||
# -XX:InitiatingHeapOccupancyPercent=35 : start concurrent GC earlier (at 35% occupancy) to
|
||||
# avoid long stop-the-world collections mid-build.
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:+UnlockDiagnosticVMOptions -XX:G1ReservePercent=5 -XX:InitiatingHeapOccupancyPercent=35
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# Run independent modules/projects concurrently instead of one after another.
|
||||
# On a multi-module build this compiles modules in parallel, cutting wall-clock build time.
|
||||
org.gradle.parallel=true
|
||||
|
||||
# Cap the number of Gradle worker processes used for parallel work.
|
||||
# 4 keeps parallelism high while leaving CPU/RAM headroom for Metro, the emulator, and the IDE.
|
||||
org.gradle.workers.max=4
|
||||
|
||||
# Enable the Gradle build cache: reuse the outputs of tasks whose inputs are unchanged
|
||||
# (across builds and clean builds), so unchanged modules are not recompiled.
|
||||
org.gradle.caching=true
|
||||
|
||||
# Explicitly enable the local (on-disk) build cache backing org.gradle.caching above.
|
||||
# (The local cache is on by default once caching is enabled; this makes the intent explicit.)
|
||||
org.gradle.build.cache.local.enabled=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
@@ -23,9 +49,12 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
android.useAndroidX=true
|
||||
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
# Mobile-only: build for physical Android devices (ARM) and skip emulator ABIs (x86, x86_64).
|
||||
# Dropping the two emulator architectures roughly halves native build time and APK size.
|
||||
# Note: x86_64 Android Studio emulators (AVDs) will NOT install this build. To run on an
|
||||
# emulator, override per build from the CLI:
|
||||
# ./gradlew <task> -PreactNativeArchitectures=arm64-v8a,armeabi-v7a,x86,x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
# This will allow you to use TurboModules and the Fabric render in
|
||||
@@ -42,3 +71,5 @@ hermesEnabled=true
|
||||
# This allows your app to draw behind system bars for an immersive UI.
|
||||
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||
edgeToEdgeEnabled=false
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,17 @@
|
||||
*/
|
||||
|
||||
import { AppRegistry, Text, TextInput } from 'react-native';
|
||||
import notifee from '@notifee/react-native';
|
||||
import App from './App';
|
||||
import { name as appName } from './app.json';
|
||||
|
||||
// Required by Notifee: handles notification interactions that happen while the
|
||||
// app is in the background or quit. Tapping a notification opens the app via
|
||||
// the default press action, so no extra work is needed here.
|
||||
notifee.onBackgroundEvent(async () => {
|
||||
// No-op: the default press action launches the app.
|
||||
});
|
||||
|
||||
const customTextProps = {
|
||||
style: {
|
||||
fontFamily: 'Nunito-Regular',
|
||||
|
||||
Generated
+10
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@maplibre/maplibre-react-native": "^10.4.2",
|
||||
"@notifee/react-native": "^9.1.8",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/geolocation": "^3.4.0",
|
||||
"@react-native-vector-icons/ionicons": "^13.0.0",
|
||||
@@ -2829,6 +2830,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@notifee/react-native": {
|
||||
"version": "9.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@notifee/react-native/-/react-native-9.1.8.tgz",
|
||||
"integrity": "sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-async-storage/async-storage": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@maplibre/maplibre-react-native": "^10.4.2",
|
||||
"@notifee/react-native": "^9.1.8",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/geolocation": "^3.4.0",
|
||||
"@react-native-vector-icons/ionicons": "^13.0.0",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { agentReducer } from '../modules/agent/store/agentSlice';
|
||||
import { schemesReducer } from '../modules/schemes/store/schemesSlice';
|
||||
import { schemesApi } from '../modules/schemes/services/schemesApi';
|
||||
import { beneficiariesReducer } from '../modules/payments/store/beneficiariesSlice';
|
||||
import { notificationsReducer } from '../modules/notifications/store/notificationsSlice';
|
||||
|
||||
const appReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
@@ -31,6 +32,7 @@ const appReducer = combineReducers({
|
||||
ui: uiReducer,
|
||||
schemes: schemesReducer,
|
||||
beneficiaries: beneficiariesReducer,
|
||||
notifications: notificationsReducer,
|
||||
agent: agentReducer,
|
||||
[baseApi.reducerPath]: baseApi.reducer,
|
||||
[schemesApi.reducerPath]: schemesApi.reducer,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AccountSummary,
|
||||
ActiveLoan,
|
||||
AppNotification,
|
||||
BankCard,
|
||||
Beneficiary,
|
||||
Branch,
|
||||
@@ -41,6 +42,57 @@ export const demoUser: UserProfile = {
|
||||
},
|
||||
};
|
||||
|
||||
export const demoNotifications: AppNotification[] = [
|
||||
{
|
||||
id: 'ntf_login',
|
||||
category: 'security',
|
||||
title: 'New sign-in detected',
|
||||
body: 'We noticed a new sign-in on a Pixel device in Bengaluru. If this was you, no action is needed.',
|
||||
createdAt: '2026-06-02T08:45:00+05:30',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 'ntf_credit',
|
||||
category: 'transaction',
|
||||
title: 'Money received',
|
||||
body: '₹24,500 was credited to your Savings account XXXX 9312 from Priya Nair.',
|
||||
createdAt: '2026-06-02T07:10:00+05:30',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 'ntf_card_bill',
|
||||
category: 'reminder',
|
||||
title: 'Credit card bill due soon',
|
||||
body: 'Your card payment of ₹12,870 is due on 8 June. Pay early to avoid interest charges.',
|
||||
createdAt: '2026-06-01T18:30:00+05:30',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 'ntf_offer',
|
||||
category: 'offer',
|
||||
title: 'Festive cashback unlocked',
|
||||
body: 'Get 5% cashback on UPI payments above ₹500 this week. Terms apply.',
|
||||
createdAt: '2026-05-31T11:00:00+05:30',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: 'ntf_fd',
|
||||
category: 'reminder',
|
||||
title: 'Fixed deposit maturing',
|
||||
body: 'Your fixed deposit of ₹1,00,000 matures on 15 June. Choose to renew or withdraw.',
|
||||
createdAt: '2026-05-30T09:20:00+05:30',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: 'ntf_kyc',
|
||||
category: 'system',
|
||||
title: 'KYC verification complete',
|
||||
body: 'Your KYC details have been verified successfully. All account features are now active.',
|
||||
createdAt: '2026-05-28T16:05:00+05:30',
|
||||
read: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const demoAccounts: AccountSummary[] = [
|
||||
{
|
||||
id: 'acc_primary',
|
||||
|
||||
@@ -65,7 +65,7 @@ export const useAppBootstrap = () => {
|
||||
useEffect(() => {
|
||||
const unregister = registerUnauthorizedHandler(() => {
|
||||
dispatch(logoutSession());
|
||||
toast('Signed out', 'info', 'Your session was refreshed for security reasons.');
|
||||
toast('Signed out', 'info', 'For your security, your session was refreshed.');
|
||||
});
|
||||
|
||||
return unregister;
|
||||
|
||||
@@ -11,7 +11,7 @@ interface LoginRoleTabsProps {
|
||||
onRoleChange: (role: LoginRole) => void;
|
||||
}
|
||||
|
||||
const ROLES: LoginRole[] = ['User', 'Employee', 'Agent'];
|
||||
const ROLES: LoginRole[] = ['User', 'Agent'];
|
||||
|
||||
const ActiveTabBackground = () => (
|
||||
<Animated.View
|
||||
|
||||
@@ -36,12 +36,12 @@ export default function LoginScreen(props: Readonly<Props>) {
|
||||
|
||||
const handleForgotPassword = () => {
|
||||
haptics.selection();
|
||||
toast('Redirecting to secure reset channel...', 'info');
|
||||
toast('Resetting your password', 'info', 'We are taking you to reset your password.');
|
||||
};
|
||||
|
||||
const handleSignUp = () => {
|
||||
haptics.selection();
|
||||
toast('Initializing onboarding sequence...', 'info');
|
||||
toast('Creating your account', 'info', 'We are taking you to open a new account.');
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
@@ -55,11 +55,11 @@ export default function LoginScreen(props: Readonly<Props>) {
|
||||
await secureStorage.saveSession(tokens);
|
||||
dispatch(authActions.authenticate(tokens));
|
||||
haptics.success();
|
||||
toast('Neural bridge established', 'success');
|
||||
toast('Signed in', 'success', 'Welcome back to your account.');
|
||||
return;
|
||||
} catch {
|
||||
haptics.error();
|
||||
toast('Access Denied', 'error', 'Authorization failed. Retry handshake.');
|
||||
toast('Sign-in failed', 'error', 'We could not verify your details. Please check and try again.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function LoginScreen(props: Readonly<Props>) {
|
||||
const success = await promptBiometric();
|
||||
|
||||
if (!success) {
|
||||
toast('Biometric scan aborted', 'info');
|
||||
toast('Biometric sign-in cancelled', 'info', 'You can try again or sign in with your password.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function LoginScreen(props: Readonly<Props>) {
|
||||
await secureStorage.saveSession(tokens);
|
||||
dispatch(authActions.authenticate(tokens));
|
||||
haptics.success();
|
||||
toast('Synapse link verified', 'success');
|
||||
toast('Signed in', 'success', 'Your identity was confirmed. Welcome back.');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -91,31 +91,31 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
toast(
|
||||
'Income too low',
|
||||
'error',
|
||||
'Enter an annual income of at least Rs 3,00,000 to continue.',
|
||||
'Please enter an annual income of at least Rs 3,00,000 to continue.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!employerName.trim()) {
|
||||
toast('Employer required', 'error', 'Enter your employer or business name.');
|
||||
toast('Employer name needed', 'error', 'Please enter your employer or business name.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!city.trim()) {
|
||||
toast('City required', 'error', 'Enter your current city to continue.');
|
||||
toast('City needed', 'error', 'Please enter your current city to continue.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedRepaymentAccount) {
|
||||
toast('Account required', 'error', 'Select a repayment account for your credit card.');
|
||||
toast('Repayment account needed', 'error', 'Please select a repayment account for your credit card.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!consentAccepted) {
|
||||
toast(
|
||||
'Consent required',
|
||||
'Consent needed',
|
||||
'error',
|
||||
'Accept the bureau and KYC consent to submit your application.',
|
||||
'Please accept the credit bureau and KYC consent to submit your application.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -200,8 +200,8 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
nextStatus === 'blocked' ? 'Card blocked' : 'Card active',
|
||||
nextStatus === 'blocked' ? 'error' : 'success',
|
||||
nextStatus === 'blocked'
|
||||
? 'All new transactions are disabled.'
|
||||
: 'Transactions are enabled again.',
|
||||
? 'New transactions on this card are now stopped.'
|
||||
: 'You can use this card again.',
|
||||
);
|
||||
};
|
||||
|
||||
@@ -217,8 +217,8 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
card.controls.frozen ? 'Card unfrozen' : 'Card frozen',
|
||||
'success',
|
||||
card.controls.frozen
|
||||
? 'The card is ready for use again.'
|
||||
: 'Temporary freeze applied successfully.',
|
||||
? 'Your card is ready to use again.'
|
||||
: 'Transactions on this card are paused for now.',
|
||||
);
|
||||
};
|
||||
|
||||
@@ -227,16 +227,16 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
|
||||
if (!authenticated) {
|
||||
toast(
|
||||
'Authentication required',
|
||||
'Verification needed',
|
||||
'info',
|
||||
'Use biometrics to reveal the full number and CVV.',
|
||||
'Confirm with your fingerprint or face to view the full number and CVV.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSensitiveVisible((currentValue) => !currentValue);
|
||||
toast(
|
||||
sensitiveVisible ? 'Sensitive details hidden' : 'Sensitive details unlocked',
|
||||
sensitiveVisible ? 'Card details hidden' : 'Card details shown',
|
||||
'success',
|
||||
);
|
||||
};
|
||||
@@ -245,11 +245,11 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
const authenticated = await promptBiometric();
|
||||
|
||||
if (!authenticated) {
|
||||
toast(actionLabel, 'info', 'Biometric confirmation is required for PIN actions.');
|
||||
toast(actionLabel, 'info', 'Please confirm with your fingerprint or face to manage your PIN.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast(actionLabel, 'success', `${actionLabel} journey started securely.`);
|
||||
toast(actionLabel, 'success', `You can now ${actionLabel.toLowerCase()} securely.`);
|
||||
};
|
||||
|
||||
const handleGenerateVirtualCard = () => {
|
||||
@@ -259,18 +259,18 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
virtualCard: createVirtualCardPayload(card.id, virtualLimitDraft),
|
||||
}),
|
||||
);
|
||||
toast('Virtual card created', 'success', 'Your online-use virtual card is ready.');
|
||||
toast('Virtual card created', 'success', 'Your virtual card for online payments is ready.');
|
||||
};
|
||||
|
||||
const handleDeleteVirtualCard = () => {
|
||||
dispatch(cardsActions.deleteVirtualCard({ cardId: card.id }));
|
||||
setVirtualLimitDraft(10000);
|
||||
toast('Virtual card deleted', 'info', 'The virtual card was removed successfully.');
|
||||
toast('Virtual card deleted', 'info', 'Your virtual card has been removed.');
|
||||
};
|
||||
|
||||
const handleRedeemRewards = () => {
|
||||
if (card.rewards.points <= 0) {
|
||||
toast('No reward points', 'info', 'There are no points available to redeem.');
|
||||
toast('No reward points', 'info', 'You do not have any points to redeem right now.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
toast(
|
||||
'Rewards redeemed',
|
||||
'success',
|
||||
`${pointsToRedeem.toLocaleString('en-IN')} points were redeemed to your reward balance.`,
|
||||
`${pointsToRedeem.toLocaleString('en-IN')} points have been added to your reward balance.`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -287,25 +287,25 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
const nextName = renameDraft.trim();
|
||||
|
||||
if (nextName.length < 3) {
|
||||
toast('Invalid card name', 'error', 'Use at least 3 characters for the new card name.');
|
||||
toast('Card name too short', 'error', 'Please use at least 3 characters for the card name.');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(cardsActions.renameCard({ cardId: card.id, name: nextName }));
|
||||
toast('Card renamed', 'success', `This card is now labeled "${nextName}".`);
|
||||
toast('Card renamed', 'success', `Your card is now named "${nextName}".`);
|
||||
};
|
||||
|
||||
const handleSetDefaultCard = () => {
|
||||
dispatch(cardsActions.setDefaultCard({ cardId: card.id }));
|
||||
toast('Default card updated', 'success', 'This card is now your default payment card.');
|
||||
toast('Default card updated', 'success', 'This card is now your default for payments.');
|
||||
};
|
||||
|
||||
const handleStatementDownload = (statement: CardStatement) => {
|
||||
toast('Statement download', 'success', `${statement.monthLabel} PDF is ready for download.`);
|
||||
toast('Download started', 'success', `Your ${statement.monthLabel} statement PDF is ready to download.`);
|
||||
};
|
||||
|
||||
const handleStatementEmail = (statement: CardStatement) => {
|
||||
toast('Statement email queued', 'info', `${statement.monthLabel} will be emailed shortly.`);
|
||||
toast('Statement on its way', 'info', `Your ${statement.monthLabel} statement will arrive in your email shortly.`);
|
||||
};
|
||||
|
||||
const budgetProgress =
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
@@ -16,6 +15,7 @@ import { useToast } from '../../../hooks/useToast';
|
||||
import type { CardsStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import { CardCarousel } from '../components/CardCarousel';
|
||||
import { CardDeliveryStatus } from '../components/CardDeliveryStatus';
|
||||
import { CardsSkeleton } from '../components/CardsSkeleton';
|
||||
@@ -260,8 +260,8 @@ export default function CardsOverviewScreen({ navigation }: Props) {
|
||||
nextStatus === 'blocked' ? 'Card blocked' : 'Card unblocked',
|
||||
nextStatus === 'blocked' ? 'error' : 'success',
|
||||
nextStatus === 'blocked'
|
||||
? 'This card is now blocked for all new transactions.'
|
||||
: 'This card is active again.',
|
||||
? 'Your card is now blocked for all new transactions.'
|
||||
: 'Your card is active again.',
|
||||
);
|
||||
};
|
||||
|
||||
@@ -296,14 +296,18 @@ export default function CardsOverviewScreen({ navigation }: Props) {
|
||||
<ScreenShell
|
||||
title="Cards"
|
||||
subtitle="Premium controls, insights, and secure card tools"
|
||||
scrollable={false}
|
||||
>
|
||||
<View style={styles.flex}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
fixedFooter={
|
||||
<Pressable
|
||||
onPress={handleRequestDebitCard}
|
||||
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
|
||||
>
|
||||
<View style={styles.heroCopy}>
|
||||
<Ionicons name="add-outline" size={24} color={theme.colors.white} />
|
||||
<Text style={styles.fabLabel}>Request Card</Text>
|
||||
</Pressable>
|
||||
}
|
||||
>
|
||||
<View style={styles.heroCopy}>
|
||||
<View>
|
||||
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
Selected wallet card
|
||||
@@ -558,32 +562,15 @@ export default function CardsOverviewScreen({ navigation }: Props) {
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<View style={styles.footerSpace} />
|
||||
</ScrollView>
|
||||
|
||||
<Pressable
|
||||
onPress={handleRequestDebitCard}
|
||||
style={[
|
||||
styles.fab,
|
||||
{ backgroundColor: theme.colors.primary },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="add-outline" size={24} color={theme.colors.white} />
|
||||
<Text style={styles.fabLabel}>Request Card</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.bottomInset} />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 120,
|
||||
// Clear the absolute tab bar + the floating "Request Card" button.
|
||||
paddingBottom: TAB_BAR_HEIGHT + 120,
|
||||
},
|
||||
emptyStateWrap: {
|
||||
gap: 20,
|
||||
@@ -805,13 +792,10 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
},
|
||||
footerSpace: {
|
||||
height: 24,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
bottom: 22,
|
||||
bottom: TAB_BAR_HEIGHT + 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
@@ -829,7 +813,4 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
bottomInset: {
|
||||
height: 60,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,17 +96,17 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedAccount) {
|
||||
toast('Unable to place request', 'error', 'Select a linked account first.');
|
||||
toast('We need an account first', 'error', 'Please choose the account you want this card linked to.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nameOnCard.trim()) {
|
||||
toast('Name required', 'error', 'Enter the cardholder name to print on the card.');
|
||||
toast('Name needed', 'error', 'Please enter the name you would like printed on your card.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deliveryMode === 'home' && !deliveryAddress.trim()) {
|
||||
toast('Address required', 'error', 'Add a delivery address for doorstep dispatch.');
|
||||
toast('Address needed', 'error', 'Please add the address where you would like your card delivered.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
|
||||
'success',
|
||||
deposit.status === 'matured'
|
||||
? 'The maturity amount has been credited to your linked account.'
|
||||
: 'Closure request completed successfully.',
|
||||
: 'Your deposit has been closed and the proceeds are on their way.',
|
||||
);
|
||||
} catch {
|
||||
toast(
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function OpenFixedDepositScreen({ navigation }: Props) {
|
||||
setErrors(validation);
|
||||
|
||||
if (Object.keys(validation).length) {
|
||||
toast('Check deposit inputs', 'error', 'Please correct the highlighted fields.');
|
||||
toast('Some details need a look', 'error', 'Please check the details below and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function OpenFixedDepositScreen({ navigation }: Props) {
|
||||
interestPayoutType: payoutType,
|
||||
}).unwrap();
|
||||
|
||||
toast('Fixed deposit created', 'success', 'Your new FD is now active.');
|
||||
toast('Fixed deposit created', 'success', 'Your new fixed deposit is now active.');
|
||||
navigation.replace('DepositDetails', {
|
||||
depositId: deposit.id,
|
||||
kind: deposit.kind,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function OpenRecurringDepositScreen({ navigation }: Props) {
|
||||
setErrors(validation);
|
||||
|
||||
if (Object.keys(validation).length) {
|
||||
toast('Check recurring deposit inputs', 'error', 'Please correct the highlighted fields.');
|
||||
toast('Some details are missing', 'error', 'Please check the details below and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,13 +98,13 @@ export default function OpenRecurringDepositScreen({ navigation }: Props) {
|
||||
tenureMonths,
|
||||
}).unwrap();
|
||||
|
||||
toast('Recurring deposit created', 'success', 'Your new RD is now active.');
|
||||
toast('Recurring deposit created', 'success', 'Your new recurring deposit is now active.');
|
||||
navigation.replace('DepositDetails', {
|
||||
depositId: deposit.id,
|
||||
kind: deposit.kind,
|
||||
});
|
||||
} catch {
|
||||
toast('Unable to open recurring deposit', 'error', 'Please try again shortly.');
|
||||
toast('We couldn\'t open your recurring deposit', 'error', 'Something went wrong on our end. Please try again in a moment.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HomeSectionHeader } from '../components/HomeSectionHeader';
|
||||
import { ServiceGridItem } from '../components/ServiceGridItem';
|
||||
import { PromoBanner } from '../components/PromoBanner';
|
||||
import { HorizontalCategories } from '../components/HorizontalCategories';
|
||||
import { selectUnreadNotificationCount } from '../../notifications/store/notificationsSlice';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
const SPACING = 16;
|
||||
@@ -27,6 +28,7 @@ export default function HomeScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const accounts = useAppSelector((state) => state.account.accounts);
|
||||
const profile = useAppSelector((state) => state.user.profile);
|
||||
const unreadNotifications = useAppSelector(selectUnreadNotificationCount);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const scrollX = useRef(new Animated.Value(0)).current;
|
||||
const account = accounts[activeIndex] || accounts[0];
|
||||
@@ -97,10 +99,23 @@ export default function HomeScreen({ navigation }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}>
|
||||
<Pressable
|
||||
style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}
|
||||
onPress={() => navigation.navigate('Notifications')}
|
||||
>
|
||||
<Ionicons name="notifications-outline" size={22} color={theme.colors.textPrimary} />
|
||||
{unreadNotifications > 0 ? (
|
||||
<View style={[styles.badge, { backgroundColor: theme.colors.danger, borderColor: theme.colors.surface }]}>
|
||||
<Text style={styles.badgeText}>
|
||||
{unreadNotifications > 9 ? '9+' : unreadNotifications}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
<Pressable style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}>
|
||||
<Pressable
|
||||
style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}
|
||||
onPress={() => navigation.navigate('GlobalSearch')}
|
||||
>
|
||||
<Ionicons name="search-outline" size={22} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -276,6 +291,23 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 5,
|
||||
elevation: 2,
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
borderWidth: 1.5,
|
||||
paddingHorizontal: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
},
|
||||
section: {
|
||||
paddingHorizontal: 6,
|
||||
marginBottom: 24,
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function BuyInsuranceScreen({ navigation, route }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast('Unable to send OTP', 'error', 'Please try again.');
|
||||
toast('Unable to send OTP', 'error', 'We could not send your code right now. Please try again.');
|
||||
};
|
||||
|
||||
const handleConfirmPurchase = async () => {
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function InsuranceClaimsScreen({ route }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast('Unable to attach document', 'error', 'Please try again.');
|
||||
toast('Couldn’t attach document', 'error', 'Something went wrong while uploading. Please try again.');
|
||||
};
|
||||
|
||||
const handleRequestOtp = async () => {
|
||||
@@ -136,7 +136,7 @@ export default function InsuranceClaimsScreen({ route }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast('Unable to send OTP', 'error', 'Please try again.');
|
||||
toast('Couldn’t send OTP', 'error', 'We couldn’t send your one-time password. Please try again.');
|
||||
};
|
||||
|
||||
const handleSubmitClaim = async () => {
|
||||
|
||||
@@ -60,9 +60,9 @@ export default function InsuranceDocumentsScreen(_props: Props) {
|
||||
const result = await uploadDocument(sample);
|
||||
|
||||
if ('data' in result) {
|
||||
toast('Document uploaded', 'success', `${sample.name} is now available in the secure vault.`);
|
||||
toast('Document uploaded', 'success', `${sample.name} is now saved securely with your documents.`);
|
||||
} else {
|
||||
toast('Upload failed', 'error', 'Please try again.');
|
||||
toast('Upload didn’t go through', 'error', 'Please check your connection and try uploading again.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function InsuranceDocumentsScreen(_props: Props) {
|
||||
<InsuranceDocumentItem
|
||||
document={item}
|
||||
onView={() =>
|
||||
toast('Document available', 'info', `${item.name} is stored in encrypted vault storage.`)
|
||||
toast('Document available', 'info', `${item.name} is saved securely with your documents.`)
|
||||
}
|
||||
onReupload={() =>
|
||||
void uploadDocument({
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function InsurancePolicyDetailsScreen({ navigation, route }: Prop
|
||||
toast(
|
||||
'Policy download ready',
|
||||
'success',
|
||||
`${data.documents[0]?.name ?? 'Policy schedule'} is ready from the secure vault.`,
|
||||
`${data.documents[0]?.name ?? 'Policy schedule'} is ready to download.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -65,10 +65,10 @@ export default function InsuranceRenewalsScreen({ route }: Props) {
|
||||
toast(
|
||||
enabled ? 'Auto-renew enabled' : 'Auto-renew disabled',
|
||||
'success',
|
||||
`${due.planName} has been updated.`,
|
||||
`Your settings for ${due.planName} have been updated.`,
|
||||
);
|
||||
} else {
|
||||
toast('Unable to update auto-renew', 'error', 'Please try again.');
|
||||
toast('Couldn\'t update auto-renew', 'error', 'Something went wrong on our end. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function InsuranceRenewalsScreen({ route }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast('Unable to send OTP', 'error', 'Please try again.');
|
||||
toast('Couldn\'t send OTP', 'error', 'We couldn\'t send your code right now. Please try again.');
|
||||
};
|
||||
|
||||
const handleConfirmPayment = async () => {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Dimensions, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
import Animated, { Easing, LinearTransition, withTiming } from 'react-native-reanimated';
|
||||
import type { ExitAnimationsValues, LayoutAnimation } from 'react-native-reanimated';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, EmptyState, ScreenShell } from '../../../components';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import type { AppNotification, NotificationCategory } from '../../../types/app';
|
||||
import type { HomeStackParamList } from '../../../navigation/types';
|
||||
import {
|
||||
displayLocalNotification,
|
||||
getDummyNotificationContent,
|
||||
scheduleLocalNotification,
|
||||
} from '../../../services/notifications/pushNotifications';
|
||||
import {
|
||||
notificationsActions,
|
||||
selectNotifications,
|
||||
selectUnreadNotificationCount,
|
||||
} from '../store/notificationsSlice';
|
||||
|
||||
type Props = NativeStackScreenProps<HomeStackParamList, 'Notifications'>;
|
||||
|
||||
type IconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
const CATEGORY_ICON: Record<NotificationCategory, IconName> = {
|
||||
transaction: 'swap-horizontal-outline',
|
||||
security: 'shield-checkmark-outline',
|
||||
offer: 'pricetag-outline',
|
||||
reminder: 'alarm-outline',
|
||||
system: 'information-circle-outline',
|
||||
};
|
||||
|
||||
const categoryColor = (
|
||||
category: NotificationCategory,
|
||||
colors: ReturnType<typeof useTheme>['colors'],
|
||||
) => {
|
||||
switch (category) {
|
||||
case 'security':
|
||||
return colors.warning;
|
||||
case 'offer':
|
||||
return colors.accent;
|
||||
case 'reminder':
|
||||
return colors.info;
|
||||
case 'system':
|
||||
return colors.textSecondary;
|
||||
case 'transaction':
|
||||
default:
|
||||
return colors.primary;
|
||||
}
|
||||
};
|
||||
|
||||
const formatRelativeTime = (iso: string) => {
|
||||
const then = new Date(iso).getTime();
|
||||
const diffMinutes = Math.round((Date.now() - then) / 60000);
|
||||
|
||||
if (!Number.isFinite(diffMinutes)) return formatDate(iso);
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return formatDate(iso);
|
||||
};
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
|
||||
// Custom exit: the tile flies off to the left while shrinking and fading,
|
||||
// so a deleted notification "vanishes" instead of blinking out.
|
||||
const vanishExit = (_values: ExitAnimationsValues): LayoutAnimation => {
|
||||
'worklet';
|
||||
return {
|
||||
initialValues: {
|
||||
opacity: 1,
|
||||
transform: [{ translateX: 0 }, { scale: 1 }],
|
||||
},
|
||||
animations: {
|
||||
opacity: withTiming(0, { duration: 220, easing: Easing.in(Easing.ease) }),
|
||||
transform: [
|
||||
{ translateX: withTiming(-SCREEN_WIDTH, { duration: 320, easing: Easing.in(Easing.cubic) }) },
|
||||
{ scale: withTiming(0.7, { duration: 320, easing: Easing.in(Easing.cubic) }) },
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function NotificationsScreen(_props: Props) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const toast = useToast();
|
||||
const notifications = useAppSelector(selectNotifications);
|
||||
const unreadCount = useAppSelector(selectUnreadNotificationCount);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...notifications].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
),
|
||||
[notifications],
|
||||
);
|
||||
|
||||
const subtitle =
|
||||
unreadCount > 0
|
||||
? `${unreadCount} unread ${unreadCount === 1 ? 'update' : 'updates'}`
|
||||
: "You're all caught up";
|
||||
|
||||
const handlePress = useCallback(
|
||||
(notification: AppNotification) => {
|
||||
if (!notification.read) {
|
||||
dispatch(notificationsActions.markAsRead(notification.id));
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleMarkAll = useCallback(() => {
|
||||
dispatch(notificationsActions.markAllRead());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
dispatch(notificationsActions.removeNotification(id));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Foreground demo: pop a system heads-up notification now and add it to the list.
|
||||
const handleSendNow = useCallback(async () => {
|
||||
const content = getDummyNotificationContent();
|
||||
dispatch(
|
||||
notificationsActions.addNotification({
|
||||
id: `ntf_${Date.now()}`,
|
||||
category: content.category,
|
||||
title: content.title,
|
||||
body: content.body,
|
||||
createdAt: new Date().toISOString(),
|
||||
read: false,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await displayLocalNotification(content);
|
||||
} catch {
|
||||
toast(
|
||||
'Added to your list',
|
||||
'info',
|
||||
'Rebuild the app to also get the system pop-up.',
|
||||
);
|
||||
}
|
||||
}, [dispatch, toast]);
|
||||
|
||||
// Background demo: schedule a notification, then minimise the app to see it arrive.
|
||||
const handleSchedule = useCallback(async () => {
|
||||
const content = getDummyNotificationContent();
|
||||
try {
|
||||
await scheduleLocalNotification(content, 5);
|
||||
toast(
|
||||
'Notification scheduled',
|
||||
'info',
|
||||
'Minimise the app — it arrives in about 5 seconds.',
|
||||
);
|
||||
} catch {
|
||||
toast("Couldn't schedule", 'error', 'Rebuild the app to enable scheduled notifications.');
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: AppNotification }) => {
|
||||
const accent = categoryColor(item.category, theme.colors);
|
||||
|
||||
const renderRightActions = () => (
|
||||
<Pressable
|
||||
onPress={() => handleDelete(item.id)}
|
||||
style={[styles.deleteAction, { backgroundColor: theme.colors.danger }]}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={22} color={theme.colors.white} />
|
||||
<Text style={styles.deleteText}>Delete</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
exiting={vanishExit}
|
||||
layout={LinearTransition.springify().damping(16).stiffness(170).mass(0.6)}
|
||||
>
|
||||
<Swipeable
|
||||
renderRightActions={renderRightActions}
|
||||
overshootRight={false}
|
||||
rightThreshold={40}
|
||||
friction={2}
|
||||
>
|
||||
<Pressable onPress={() => handlePress(item)}>
|
||||
<Card style={[styles.card, !item.read && styles.cardUnread]}>
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.iconWrap, { backgroundColor: `${accent}18` }]}>
|
||||
<Ionicons name={CATEGORY_ICON[item.category]} size={20} color={accent} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={[styles.body, { color: theme.colors.textSecondary }]}>
|
||||
{item.body}
|
||||
</Text>
|
||||
<Text style={[styles.time, { color: theme.colors.textMuted }]}>
|
||||
{formatRelativeTime(item.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
{!item.read ? (
|
||||
<View style={[styles.unreadDot, { backgroundColor: theme.colors.primary }]} />
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
</Swipeable>
|
||||
</Animated.View>
|
||||
);
|
||||
},
|
||||
[handlePress, handleDelete, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Notifications"
|
||||
subtitle={subtitle}
|
||||
scrollable={false}
|
||||
rightContent={
|
||||
unreadCount > 0 ? (
|
||||
<Pressable onPress={handleMarkAll} hitSlop={8} style={styles.markAllButton}>
|
||||
<Text style={[styles.markAllText, { color: theme.colors.primary }]}>Mark all read</Text>
|
||||
</Pressable>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<FlatList
|
||||
data={sorted}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
ListHeaderComponent={
|
||||
<Card style={styles.demoCard}>
|
||||
<Text style={[styles.demoTitle, { color: theme.colors.textPrimary }]}>
|
||||
Try a notification
|
||||
</Text>
|
||||
<Text style={[styles.demoSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Send a dummy alert now, or schedule one and minimise the app.
|
||||
</Text>
|
||||
<View style={styles.demoButtons}>
|
||||
<Button
|
||||
title="Send now"
|
||||
fullWidth={false}
|
||||
style={styles.demoButton}
|
||||
onPress={handleSendNow}
|
||||
leftAccessory={
|
||||
<Ionicons name="notifications-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
title="In 5s"
|
||||
variant="secondary"
|
||||
fullWidth={false}
|
||||
style={styles.demoButton}
|
||||
onPress={handleSchedule}
|
||||
leftAccessory={
|
||||
<Ionicons name="time-outline" size={18} color={theme.colors.textPrimary} />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No notifications"
|
||||
description="Account alerts, reminders, and offers will show up here."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const ItemSeparator = () => <View style={styles.separator} />;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
separator: {
|
||||
height: 10,
|
||||
},
|
||||
demoCard: {
|
||||
padding: 16,
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
demoTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
demoSubtitle: {
|
||||
fontSize: 12.5,
|
||||
lineHeight: 18,
|
||||
},
|
||||
demoButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: 4,
|
||||
},
|
||||
demoButton: {
|
||||
flex: 1,
|
||||
},
|
||||
card: {
|
||||
padding: 14,
|
||||
},
|
||||
// Unread highlight: an OPAQUE tint + border. A translucent background here
|
||||
// makes Android render the card's elevation shadow as a grey box around it.
|
||||
cardUnread: {
|
||||
backgroundColor: '#EDF3FA',
|
||||
borderColor: '#D2E0EF',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 3,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
body: {
|
||||
fontSize: 12.5,
|
||||
lineHeight: 18,
|
||||
},
|
||||
time: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
marginTop: 2,
|
||||
},
|
||||
unreadDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
marginTop: 6,
|
||||
},
|
||||
deleteAction: {
|
||||
width: 88,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
borderTopRightRadius: 12,
|
||||
borderBottomRightRadius: 12,
|
||||
},
|
||||
deleteText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
},
|
||||
markAllButton: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
markAllText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { AppNotification } from '../../../types/app';
|
||||
import type { RootState } from '../../../app/store';
|
||||
import { demoNotifications } from '../../../constants/mockData';
|
||||
|
||||
interface NotificationsState {
|
||||
list: AppNotification[];
|
||||
}
|
||||
|
||||
const initialState: NotificationsState = {
|
||||
list: demoNotifications,
|
||||
};
|
||||
|
||||
const notificationsSlice = createSlice({
|
||||
name: 'notifications',
|
||||
initialState,
|
||||
reducers: {
|
||||
setNotifications(state, action: PayloadAction<AppNotification[]>) {
|
||||
state.list = action.payload;
|
||||
},
|
||||
addNotification(state, action: PayloadAction<AppNotification>) {
|
||||
state.list.unshift(action.payload);
|
||||
},
|
||||
markAsRead(state, action: PayloadAction<string>) {
|
||||
const notification = state.list.find((item) => item.id === action.payload);
|
||||
if (notification) {
|
||||
notification.read = true;
|
||||
}
|
||||
},
|
||||
markAllRead(state) {
|
||||
state.list.forEach((notification) => {
|
||||
notification.read = true;
|
||||
});
|
||||
},
|
||||
removeNotification(state, action: PayloadAction<string>) {
|
||||
state.list = state.list.filter((item) => item.id !== action.payload);
|
||||
},
|
||||
clearAll(state) {
|
||||
state.list = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const notificationsActions = notificationsSlice.actions;
|
||||
export const notificationsReducer = notificationsSlice.reducer;
|
||||
|
||||
// Selectors — the slice is the single source of truth for in-app notifications.
|
||||
export const selectNotifications = (state: RootState) => state.notifications.list;
|
||||
|
||||
export const selectUnreadNotificationCount = (state: RootState) =>
|
||||
state.notifications.list.reduce((count, item) => (item.read ? count : count + 1), 0);
|
||||
@@ -4,7 +4,6 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { ScreenShell, Input, Button, Card, AppModal } from '../../../components';
|
||||
import { useAddBeneficiaryMutation } from '../services/paymentsApi';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { beneficiariesActions } from '../store/beneficiariesSlice';
|
||||
@@ -12,7 +11,6 @@ import { beneficiariesActions } from '../store/beneficiariesSlice';
|
||||
type Props = NativeStackScreenProps<PaymentsStackParamList, 'AddBeneficiary'>;
|
||||
|
||||
export default function AddBeneficiaryScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const [addBen, { isLoading }] = useAddBeneficiaryMutation();
|
||||
|
||||
@@ -26,36 +24,53 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
|
||||
|
||||
const [otpModalVisible, setOtpModalVisible] = useState(false);
|
||||
const [otp, setOtp] = useState('');
|
||||
const [otpError, setOtpError] = useState<string | undefined>(undefined);
|
||||
|
||||
const isValid =
|
||||
form.name.length > 2 &&
|
||||
form.name.trim().length > 2 &&
|
||||
form.accountNumber.length > 6 &&
|
||||
form.accountNumber === form.confirmAccountNumber &&
|
||||
form.ifsc.length === 11;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isValid) return;
|
||||
setOtp('');
|
||||
setOtpError(undefined);
|
||||
setOtpModalVisible(true);
|
||||
};
|
||||
|
||||
const closeOtpModal = () => {
|
||||
setOtpModalVisible(false);
|
||||
setOtp('');
|
||||
setOtpError(undefined);
|
||||
};
|
||||
|
||||
const confirmOTPAndSave = async () => {
|
||||
if (otp !== '123456') return; // Mock OTP validation
|
||||
if (otp !== '123456') {
|
||||
setOtpError('That code is incorrect. Use 123456 for this demo.');
|
||||
return;
|
||||
}
|
||||
|
||||
setOtpModalVisible(false);
|
||||
|
||||
try {
|
||||
const nickname = form.nickname.trim() || undefined;
|
||||
const newBeneficiaryObj = {
|
||||
name: form.name,
|
||||
bankName: form.ifsc.startsWith('HDFC') ? 'HDFC Bank' : 'State Bank of India', // mock logic
|
||||
name: form.name.trim(),
|
||||
bankName: form.ifsc.toUpperCase().startsWith('HDFC') ? 'HDFC Bank' : 'State Bank of India', // mock logic
|
||||
accountMasked: form.accountNumber.slice(-4).padStart(8, 'X'),
|
||||
nickname: form.nickname,
|
||||
nickname,
|
||||
};
|
||||
|
||||
const payload = await addBen(newBeneficiaryObj).unwrap();
|
||||
dispatch(beneficiariesActions.addBeneficiary(payload));
|
||||
// Save into the beneficiaries slice — the single source of truth that
|
||||
// every payments screen reads, so the new payee shows up immediately.
|
||||
dispatch(beneficiariesActions.addBeneficiary({ ...payload, nickname }));
|
||||
navigation.goBack();
|
||||
} catch (e) {
|
||||
console.warn("Failed to add beneficiary", e);
|
||||
setOtpError('We could not save this payee. Please try again.');
|
||||
setOtpModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,8 +103,10 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
|
||||
label="IFSC Code"
|
||||
placeholder="e.g. SBIN0001234"
|
||||
autoCapitalize="characters"
|
||||
maxLength={11}
|
||||
value={form.ifsc}
|
||||
onChangeText={(v) => setForm({ ...form, ifsc: v })}
|
||||
error={form.ifsc.length > 0 && form.ifsc.length !== 11 ? 'IFSC code must be 11 characters' : undefined}
|
||||
/>
|
||||
<Input
|
||||
label="Nickname (Optional)"
|
||||
@@ -109,7 +126,7 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
|
||||
|
||||
<AppModal
|
||||
visible={otpModalVisible}
|
||||
onClose={() => setOtpModalVisible(false)}
|
||||
onClose={closeOtpModal}
|
||||
title="Bank Authentication"
|
||||
>
|
||||
<View style={{ gap: 16 }}>
|
||||
@@ -119,7 +136,11 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChangeText={setOtp}
|
||||
onChangeText={(v) => {
|
||||
setOtp(v);
|
||||
if (otpError) setOtpError(undefined);
|
||||
}}
|
||||
error={otpError}
|
||||
/>
|
||||
<Button
|
||||
title="Confirm Addition"
|
||||
|
||||
@@ -4,7 +4,8 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { ScreenShell, Card, Input } from '../../../components';
|
||||
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { selectBeneficiaries } from '../store/beneficiariesSlice';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
@@ -13,7 +14,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'BeneficiariesList'>
|
||||
|
||||
export default function BeneficiariesListScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { data: beneficiaries = [] } = useGetBeneficiariesQuery();
|
||||
const beneficiaries = useAppSelector(selectBeneficiaries);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = beneficiaries.filter(b =>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, View, ScrollView } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { ScreenShell, Button, Card, AppModal, Input } from '../../../components';
|
||||
import { useGetBeneficiariesQuery, useSubmitBankTransferMutation } from '../services/paymentsApi';
|
||||
import { useSubmitBankTransferMutation } from '../services/paymentsApi';
|
||||
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
import { accountActions } from '../../home/store/accountSlice';
|
||||
@@ -16,8 +17,7 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
const dispatch = useDispatch();
|
||||
const { beneficiaryId, transferType, amount, remarks } = route.params;
|
||||
|
||||
const { data: beneficiaries } = useGetBeneficiariesQuery();
|
||||
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
|
||||
const beneficiary = useSelector(selectBeneficiaryById(beneficiaryId));
|
||||
|
||||
const [submitTransfer, { isLoading }] = useSubmitBankTransferMutation();
|
||||
const [otpModalVisible, setOtpModalVisible] = useState(false);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { ScreenShell, Input, Button, Card } from '../../../components';
|
||||
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
|
||||
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
import type { RootState } from '../../../app/store';
|
||||
@@ -18,8 +18,7 @@ export function PaymentFormScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const beneficiaryId = route.params?.beneficiaryId;
|
||||
const transferType = route.params?.transferType;
|
||||
const { data: beneficiaries } = useGetBeneficiariesQuery();
|
||||
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
|
||||
const beneficiary = useSelector(selectBeneficiaryById(beneficiaryId));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [amount, setAmount] = useState('');
|
||||
|
||||
@@ -4,7 +4,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { Card, ScreenShell } from '../../../components';
|
||||
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
|
||||
import { selectBeneficiaries } from '../store/beneficiariesSlice';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
@@ -16,7 +16,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentsHub'>;
|
||||
export default function PaymentsHomeScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const transactions = useAppSelector(state => state.account.transactions);
|
||||
const { data: beneficiaries = [] } = useGetBeneficiariesQuery();
|
||||
const beneficiaries = useAppSelector(selectBeneficiaries);
|
||||
|
||||
// Simulated stats
|
||||
const dailyLimit = 500000;
|
||||
|
||||
@@ -4,7 +4,8 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { ScreenShell, Card } from '../../../components';
|
||||
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
|
||||
@@ -13,8 +14,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransferTypeSelecti
|
||||
export default function TransferTypeScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const { beneficiaryId } = route.params;
|
||||
const { data: beneficiaries } = useGetBeneficiariesQuery();
|
||||
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
|
||||
const beneficiary = useAppSelector(selectBeneficiaryById(beneficiaryId));
|
||||
|
||||
const handleSelect = (type: 'IMPS' | 'NEFT' | 'RTGS') => {
|
||||
navigation.navigate('PaymentForm', { beneficiaryId, transferType: type });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { Beneficiary } from '../../../types/app';
|
||||
import type { RootState } from '../../../app/store';
|
||||
import { demoBeneficiaries } from '../../../constants/mockData';
|
||||
|
||||
interface BeneficiariesState {
|
||||
@@ -34,3 +35,9 @@ const beneficiariesSlice = createSlice({
|
||||
|
||||
export const beneficiariesActions = beneficiariesSlice.actions;
|
||||
export const beneficiariesReducer = beneficiariesSlice.reducer;
|
||||
|
||||
// Selectors — the slice is the single source of truth for saved payees, so a
|
||||
// newly added beneficiary is immediately visible everywhere it is read.
|
||||
export const selectBeneficiaries = (state: RootState) => state.beneficiaries.list;
|
||||
export const selectBeneficiaryById = (id: string) => (state: RootState) =>
|
||||
state.beneficiaries.list.find((beneficiary) => beneficiary.id === id);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
Keyboard,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { EmptyState, Input, ScreenShell } from '../../../components';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { selectSelectedAccount } from '../../home/store/accountSelectors';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { HomeStackParamList, MainTabParamList } from '../../../navigation/types';
|
||||
import {
|
||||
buildUserSearchCatalog,
|
||||
filterFeatures,
|
||||
type SearchableFeature,
|
||||
} from '../searchCatalog';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<HomeStackParamList, 'GlobalSearch'>,
|
||||
BottomTabScreenProps<MainTabParamList>
|
||||
>;
|
||||
|
||||
export default function GlobalSearchScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const selectedAccount = useAppSelector(selectSelectedAccount);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const catalog = useMemo(
|
||||
() => buildUserSearchCatalog({ primaryAccountId: selectedAccount?.id }),
|
||||
[selectedAccount?.id],
|
||||
);
|
||||
|
||||
const results = useMemo(() => filterFeatures(catalog, query), [catalog, query]);
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
const isSearching = trimmedQuery.length > 0;
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(feature: SearchableFeature) => {
|
||||
Keyboard.dismiss();
|
||||
// Dismiss the search screen first so it isn't left behind on the Home
|
||||
// stack, then route to the feature (which may switch tabs).
|
||||
navigation.goBack();
|
||||
feature.navigate(navigation);
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: SearchableFeature }) => (
|
||||
<Pressable
|
||||
onPress={() => handleSelect(item)}
|
||||
style={({ pressed }) => [
|
||||
styles.resultRow,
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.resultIcon, { backgroundColor: `${item.iconColor}18` }]}>
|
||||
<Ionicons name={item.icon} size={20} color={item.iconColor} />
|
||||
</View>
|
||||
<View style={styles.resultCopy}>
|
||||
<Text style={[styles.resultTitle, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.resultSubtitle, { color: theme.colors.textSecondary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.resultTrailing}>
|
||||
<View style={[styles.moduleChip, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.moduleChipText, { color: theme.colors.textMuted }]}>
|
||||
{item.module}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color={theme.colors.textMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
),
|
||||
[handleSelect, theme],
|
||||
);
|
||||
|
||||
const listHeader = (
|
||||
<Text style={[styles.listHeader, { color: theme.colors.textMuted }]}>
|
||||
{isSearching
|
||||
? `${results.length} ${results.length === 1 ? 'result' : 'results'} for "${trimmedQuery}"`
|
||||
: 'Browse everything you can do'}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Search" subtitle="Find any feature" scrollable={false}>
|
||||
<Input
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search for a feature, e.g. fixed deposit"
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="search"
|
||||
leftAccessory={
|
||||
<Ionicons name="search-outline" size={20} color={theme.colors.textMuted} />
|
||||
}
|
||||
rightAccessory={
|
||||
query.length > 0 ? (
|
||||
<Pressable onPress={() => setQuery('')} hitSlop={12}>
|
||||
<Ionicons name="close-circle" size={20} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={results}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListHeaderComponent={listHeader}
|
||||
contentContainerStyle={styles.listContent}
|
||||
style={styles.list}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No matches"
|
||||
description={`We couldn't find anything for "${trimmedQuery}". Try a different word.`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
gap: 10,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
listHeader: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.4,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 4,
|
||||
},
|
||||
resultRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
},
|
||||
resultIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
resultCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
resultTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
resultSubtitle: {
|
||||
fontSize: 12.5,
|
||||
fontWeight: '500',
|
||||
},
|
||||
resultTrailing: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
moduleChip: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
},
|
||||
moduleChipText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,675 @@
|
||||
import React from 'react';
|
||||
import type { CompositeNavigationProp } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { HomeStackParamList, MainTabParamList } from '../../navigation/types';
|
||||
|
||||
export type IconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
/**
|
||||
* Navigation prop available to the global search screen. It is composed of the
|
||||
* Home stack (where the search screen lives) and the bottom-tab navigator, so a
|
||||
* single feature entry can navigate to any screen in the user app — its own tab
|
||||
* or another one.
|
||||
*/
|
||||
export type GlobalSearchNavigation = CompositeNavigationProp<
|
||||
NativeStackNavigationProp<HomeStackParamList, 'GlobalSearch'>,
|
||||
BottomTabNavigationProp<MainTabParamList>
|
||||
>;
|
||||
|
||||
/** A single searchable feature/destination in the user app. */
|
||||
export type SearchableFeature = {
|
||||
/** Stable id, used as the list key. */
|
||||
id: string;
|
||||
/** Human-readable feature name shown as the result title. */
|
||||
title: string;
|
||||
/** Short description shown under the title. */
|
||||
subtitle: string;
|
||||
/** Module/group this feature belongs to (shown as a chip and used in matching). */
|
||||
module: string;
|
||||
icon: IconName;
|
||||
iconColor: string;
|
||||
/** Extra terms (synonyms, abbreviations) so search matches what people actually type. */
|
||||
keywords: string[];
|
||||
/** Performs the navigation to this feature. */
|
||||
navigate: (nav: GlobalSearchNavigation) => void;
|
||||
};
|
||||
|
||||
/** Context the catalog needs to build account-specific destinations. */
|
||||
export type SearchCatalogContext = {
|
||||
/** Account to deep-link account-specific features to (e.g. mini statement). */
|
||||
primaryAccountId?: string;
|
||||
};
|
||||
|
||||
const MODULE_COLORS = {
|
||||
accounts: '#124577',
|
||||
payments: '#10B981',
|
||||
cards: '#6366F1',
|
||||
services: '#3B82F6',
|
||||
loans: '#F59E0B',
|
||||
insurance: '#EC4899',
|
||||
profile: '#8B5CF6',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Builds the full list of searchable features for the user (customer) app.
|
||||
* Account-specific entries are only included when an account id is available.
|
||||
*/
|
||||
export function buildUserSearchCatalog(
|
||||
context: SearchCatalogContext = {},
|
||||
): SearchableFeature[] {
|
||||
const { primaryAccountId } = context;
|
||||
|
||||
const features: SearchableFeature[] = [
|
||||
// ---- Accounts (Home) ----
|
||||
{
|
||||
id: 'accounts-overview',
|
||||
title: 'Home',
|
||||
subtitle: 'Your accounts and balances',
|
||||
module: 'Accounts',
|
||||
icon: 'home-outline',
|
||||
iconColor: MODULE_COLORS.accounts,
|
||||
keywords: ['dashboard', 'balance', 'accounts', 'main'],
|
||||
navigate: (nav) => nav.navigate('Home', { screen: 'HomeMain' }),
|
||||
},
|
||||
|
||||
// ---- Payments ----
|
||||
{
|
||||
id: 'payments-hub',
|
||||
title: 'Payments',
|
||||
subtitle: 'Send money and manage transfers',
|
||||
module: 'Payments',
|
||||
icon: 'wallet-outline',
|
||||
iconColor: MODULE_COLORS.payments,
|
||||
keywords: ['transfer', 'send money', 'pay', 'imps', 'neft', 'rtgs', 'fund transfer'],
|
||||
navigate: (nav) => nav.navigate('Payments', { screen: 'PaymentsHub' }),
|
||||
},
|
||||
{
|
||||
id: 'payments-beneficiaries',
|
||||
title: 'Beneficiaries',
|
||||
subtitle: 'View and manage your payees',
|
||||
module: 'Payments',
|
||||
icon: 'people-outline',
|
||||
iconColor: MODULE_COLORS.payments,
|
||||
keywords: ['payee', 'recipient', 'contacts', 'manage payee'],
|
||||
navigate: (nav) => nav.navigate('Payments', { screen: 'BeneficiariesList' }),
|
||||
},
|
||||
{
|
||||
id: 'payments-add-beneficiary',
|
||||
title: 'Add beneficiary',
|
||||
subtitle: 'Add a new payee to send money to',
|
||||
module: 'Payments',
|
||||
icon: 'person-add-outline',
|
||||
iconColor: MODULE_COLORS.payments,
|
||||
keywords: ['new payee', 'add payee', 'add recipient'],
|
||||
navigate: (nav) => nav.navigate('Payments', { screen: 'AddBeneficiary' }),
|
||||
},
|
||||
|
||||
// ---- Cards ----
|
||||
{
|
||||
id: 'cards-overview',
|
||||
title: 'My cards',
|
||||
subtitle: 'View and manage your cards',
|
||||
module: 'Cards',
|
||||
icon: 'card-outline',
|
||||
iconColor: MODULE_COLORS.cards,
|
||||
keywords: ['debit card', 'credit card', 'card controls', 'freeze card', 'block card'],
|
||||
navigate: (nav) => nav.navigate('Cards', { screen: 'CardsOverview' }),
|
||||
},
|
||||
{
|
||||
id: 'cards-apply-credit',
|
||||
title: 'Apply for a credit card',
|
||||
subtitle: 'Check offers and apply',
|
||||
module: 'Cards',
|
||||
icon: 'add-circle-outline',
|
||||
iconColor: MODULE_COLORS.cards,
|
||||
keywords: ['new credit card', 'credit card application'],
|
||||
navigate: (nav) => nav.navigate('Cards', { screen: 'ApplyCreditCard' }),
|
||||
},
|
||||
{
|
||||
id: 'cards-request-physical',
|
||||
title: 'Request a debit card',
|
||||
subtitle: 'Order a new physical card',
|
||||
module: 'Cards',
|
||||
icon: 'mail-outline',
|
||||
iconColor: MODULE_COLORS.cards,
|
||||
keywords: ['physical card', 'new debit card', 'order card', 'reissue card'],
|
||||
navigate: (nav) => nav.navigate('Cards', { screen: 'RequestPhysicalCard' }),
|
||||
},
|
||||
|
||||
// ---- Services / Deposits ----
|
||||
{
|
||||
id: 'services-hub',
|
||||
title: 'Service desk',
|
||||
subtitle: 'All service options in one place',
|
||||
module: 'Services',
|
||||
icon: 'grid-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['services', 'hub', 'help', 'requests'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'ServicesHub' }),
|
||||
},
|
||||
{
|
||||
id: 'services-deposits',
|
||||
title: 'Deposits',
|
||||
subtitle: 'View your fixed and recurring deposits',
|
||||
module: 'Services',
|
||||
icon: 'briefcase-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['fd', 'rd', 'fixed deposit', 'recurring deposit', 'term deposit'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'DepositsOverview' }),
|
||||
},
|
||||
{
|
||||
id: 'services-open-fd',
|
||||
title: 'Open a fixed deposit',
|
||||
subtitle: 'Start a new fixed deposit',
|
||||
module: 'Services',
|
||||
icon: 'trending-up-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['fd', 'new fixed deposit', 'invest'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'OpenFixedDeposit' }),
|
||||
},
|
||||
{
|
||||
id: 'services-open-rd',
|
||||
title: 'Open a recurring deposit',
|
||||
subtitle: 'Start a new recurring deposit',
|
||||
module: 'Services',
|
||||
icon: 'repeat-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['rd', 'new recurring deposit', 'monthly savings'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'OpenRecurringDeposit' }),
|
||||
},
|
||||
{
|
||||
id: 'services-deposit-calculator',
|
||||
title: 'Deposit calculator',
|
||||
subtitle: 'Estimate FD and RD returns',
|
||||
module: 'Services',
|
||||
icon: 'calculator-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['fd calculator', 'rd calculator', 'interest', 'maturity', 'returns'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'DepositCalculator' }),
|
||||
},
|
||||
{
|
||||
id: 'services-cheque',
|
||||
title: 'Cheque services',
|
||||
subtitle: 'Cheque book, stop payment and tracking',
|
||||
module: 'Services',
|
||||
icon: 'book-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['cheque', 'check', 'chequebook'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'ChequeServicesHub' }),
|
||||
},
|
||||
{
|
||||
id: 'services-cheque-book',
|
||||
title: 'Request a cheque book',
|
||||
subtitle: 'Order a new cheque book',
|
||||
module: 'Services',
|
||||
icon: 'documents-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['chequebook', 'new cheque book', 'order cheques'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'ChequeBookRequest' }),
|
||||
},
|
||||
{
|
||||
id: 'services-stop-payment',
|
||||
title: 'Stop a cheque payment',
|
||||
subtitle: 'Block payment on a cheque',
|
||||
module: 'Services',
|
||||
icon: 'hand-left-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['stop cheque', 'block cheque', 'stop payment'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'StopPayment' }),
|
||||
},
|
||||
{
|
||||
id: 'services-positive-pay',
|
||||
title: 'Positive Pay',
|
||||
subtitle: 'Confirm cheque details before clearing',
|
||||
module: 'Services',
|
||||
icon: 'shield-checkmark-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['cheque verification', 'positive pay system', 'pps'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'PositivePay' }),
|
||||
},
|
||||
{
|
||||
id: 'services-certificates',
|
||||
title: 'Certificates',
|
||||
subtitle: 'Interest and balance certificates',
|
||||
module: 'Services',
|
||||
icon: 'document-text-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['interest certificate', 'balance certificate', 'tds', 'statement'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'CertificatesHub' }),
|
||||
},
|
||||
{
|
||||
id: 'services-requests',
|
||||
title: 'Service requests',
|
||||
subtitle: 'Track and raise service requests',
|
||||
module: 'Services',
|
||||
icon: 'git-pull-request-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['complaint', 'request', 'ticket', 'support request'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'ServiceRequestsHub' }),
|
||||
},
|
||||
{
|
||||
id: 'services-special-instructions',
|
||||
title: 'Special instructions',
|
||||
subtitle: 'Send a standing instruction to your branch',
|
||||
module: 'Services',
|
||||
icon: 'create-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['standing instruction', 'note to branch'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'SpecialInstructions' }),
|
||||
},
|
||||
{
|
||||
id: 'services-form-15g15h',
|
||||
title: 'Form 15G / 15H',
|
||||
subtitle: 'Submit your tax declaration',
|
||||
module: 'Services',
|
||||
icon: 'reader-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['tax', 'tds', 'declaration', '15g', '15h'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'Form15G15H' }),
|
||||
},
|
||||
{
|
||||
id: 'services-branch-locator',
|
||||
title: 'Find a branch or ATM',
|
||||
subtitle: 'Locate nearby branches and ATMs',
|
||||
module: 'Services',
|
||||
icon: 'map-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['atm', 'branch', 'nearby', 'location', 'directions'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'BranchLocator' }),
|
||||
},
|
||||
{
|
||||
id: 'services-awareness',
|
||||
title: 'Cyber security awareness',
|
||||
subtitle: 'Stay safe from fraud and scams',
|
||||
module: 'Services',
|
||||
icon: 'shield-half-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['fraud', 'scam', 'safety', 'security tips', 'awareness'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'AwarenessList' }),
|
||||
},
|
||||
{
|
||||
id: 'services-financial-protection',
|
||||
title: 'Protection & schemes',
|
||||
subtitle: 'Government schemes and deposit protection',
|
||||
module: 'Services',
|
||||
icon: 'umbrella-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['schemes', 'protection', 'social security', 'pmjjby', 'pmsby'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'FinancialProtectionMain' }),
|
||||
},
|
||||
{
|
||||
id: 'services-dicgc',
|
||||
title: 'Deposit insurance (DICGC)',
|
||||
subtitle: 'How your deposits are protected',
|
||||
module: 'Services',
|
||||
icon: 'lock-closed-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['dicgc', 'deposit insurance', 'guarantee'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'DicgcHub' }),
|
||||
},
|
||||
{
|
||||
id: 'services-deaf',
|
||||
title: 'Unclaimed deposits (DEAF)',
|
||||
subtitle: 'Claim inactive or unclaimed deposits',
|
||||
module: 'Services',
|
||||
icon: 'time-outline',
|
||||
iconColor: MODULE_COLORS.services,
|
||||
keywords: ['deaf', 'unclaimed', 'inactive account', 'dormant'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'DeafHub' }),
|
||||
},
|
||||
|
||||
// ---- Loans (Home > LoanFlow) ----
|
||||
{
|
||||
id: 'loans-dashboard',
|
||||
title: 'Loans',
|
||||
subtitle: 'View and manage your loans',
|
||||
module: 'Loans',
|
||||
icon: 'cash-outline',
|
||||
iconColor: MODULE_COLORS.loans,
|
||||
keywords: ['loan', 'borrow', 'emi', 'home loan', 'personal loan', 'gold loan'],
|
||||
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanDashboard' }),
|
||||
},
|
||||
{
|
||||
id: 'loans-types',
|
||||
title: 'Explore loan types',
|
||||
subtitle: 'Browse available loan products',
|
||||
module: 'Loans',
|
||||
icon: 'list-outline',
|
||||
iconColor: MODULE_COLORS.loans,
|
||||
keywords: ['loan products', 'types of loan'],
|
||||
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanTypesListing' }),
|
||||
},
|
||||
{
|
||||
id: 'loans-eligibility',
|
||||
title: 'Check loan eligibility',
|
||||
subtitle: 'See how much you can borrow',
|
||||
module: 'Loans',
|
||||
icon: 'checkmark-circle-outline',
|
||||
iconColor: MODULE_COLORS.loans,
|
||||
keywords: ['eligibility', 'qualify', 'how much loan'],
|
||||
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanEligibilityCheck', params: {} }),
|
||||
},
|
||||
{
|
||||
id: 'loans-calculator',
|
||||
title: 'EMI calculator',
|
||||
subtitle: 'Estimate your loan EMIs',
|
||||
module: 'Loans',
|
||||
icon: 'calculator-outline',
|
||||
iconColor: MODULE_COLORS.loans,
|
||||
keywords: ['emi', 'loan calculator', 'interest', 'repayment'],
|
||||
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanCalculator', params: {} }),
|
||||
},
|
||||
{
|
||||
id: 'loans-tracking',
|
||||
title: 'Track loan application',
|
||||
subtitle: 'Check your application status',
|
||||
module: 'Loans',
|
||||
icon: 'navigate-outline',
|
||||
iconColor: MODULE_COLORS.loans,
|
||||
keywords: ['application status', 'loan status'],
|
||||
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanTracking' }),
|
||||
},
|
||||
{
|
||||
id: 'loans-history',
|
||||
title: 'Loan history',
|
||||
subtitle: 'See your past and active loans',
|
||||
module: 'Loans',
|
||||
icon: 'receipt-outline',
|
||||
iconColor: MODULE_COLORS.loans,
|
||||
keywords: ['past loans', 'closed loans'],
|
||||
navigate: (nav) => nav.navigate('LoanFlow', { screen: 'LoanHistory' }),
|
||||
},
|
||||
|
||||
// ---- Insurance (Home > InsuranceFlow) ----
|
||||
{
|
||||
id: 'insurance-dashboard',
|
||||
title: 'Insurance',
|
||||
subtitle: 'Manage your policies and cover',
|
||||
module: 'Insurance',
|
||||
icon: 'shield-outline',
|
||||
iconColor: MODULE_COLORS.insurance,
|
||||
keywords: ['policy', 'cover', 'life insurance', 'health insurance'],
|
||||
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsuranceDashboard' }),
|
||||
},
|
||||
{
|
||||
id: 'insurance-buy',
|
||||
title: 'Buy insurance',
|
||||
subtitle: 'Explore and purchase a new plan',
|
||||
module: 'Insurance',
|
||||
icon: 'add-circle-outline',
|
||||
iconColor: MODULE_COLORS.insurance,
|
||||
keywords: ['new policy', 'buy policy', 'get cover'],
|
||||
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'BuyInsurance' }),
|
||||
},
|
||||
{
|
||||
id: 'insurance-policies',
|
||||
title: 'My policies',
|
||||
subtitle: 'View your active insurance policies',
|
||||
module: 'Insurance',
|
||||
icon: 'folder-open-outline',
|
||||
iconColor: MODULE_COLORS.insurance,
|
||||
keywords: ['active policies', 'my cover'],
|
||||
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsurancePolicies' }),
|
||||
},
|
||||
{
|
||||
id: 'insurance-renewals',
|
||||
title: 'Insurance renewals',
|
||||
subtitle: 'Pay premiums and renew policies',
|
||||
module: 'Insurance',
|
||||
icon: 'refresh-outline',
|
||||
iconColor: MODULE_COLORS.insurance,
|
||||
keywords: ['renew', 'premium', 'pay premium', 'due'],
|
||||
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsuranceRenewals' }),
|
||||
},
|
||||
{
|
||||
id: 'insurance-claims',
|
||||
title: 'Insurance claims',
|
||||
subtitle: 'File and track insurance claims',
|
||||
module: 'Insurance',
|
||||
icon: 'medkit-outline',
|
||||
iconColor: MODULE_COLORS.insurance,
|
||||
keywords: ['claim', 'file claim', 'claim status'],
|
||||
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsuranceClaims' }),
|
||||
},
|
||||
{
|
||||
id: 'insurance-documents',
|
||||
title: 'Insurance documents',
|
||||
subtitle: 'Upload and view policy documents',
|
||||
module: 'Insurance',
|
||||
icon: 'document-attach-outline',
|
||||
iconColor: MODULE_COLORS.insurance,
|
||||
keywords: ['documents', 'kyc', 'upload'],
|
||||
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsuranceDocuments' }),
|
||||
},
|
||||
{
|
||||
id: 'insurance-calculator',
|
||||
title: 'Insurance calculator',
|
||||
subtitle: 'Estimate the cover you need',
|
||||
module: 'Insurance',
|
||||
icon: 'calculator-outline',
|
||||
iconColor: MODULE_COLORS.insurance,
|
||||
keywords: ['premium calculator', 'cover estimate'],
|
||||
navigate: (nav) => nav.navigate('InsuranceFlow', { screen: 'InsuranceCalculator' }),
|
||||
},
|
||||
|
||||
// ---- Profile & settings ----
|
||||
{
|
||||
id: 'profile-main',
|
||||
title: 'Profile',
|
||||
subtitle: 'Your profile and app settings',
|
||||
module: 'Profile',
|
||||
icon: 'person-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['account', 'settings', 'me'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'ProfileMain' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-edit',
|
||||
title: 'Edit profile',
|
||||
subtitle: 'Update your personal details',
|
||||
module: 'Profile',
|
||||
icon: 'create-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['update details', 'change name', 'contact details'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'EditProfile' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-accounts',
|
||||
title: 'Account management',
|
||||
subtitle: 'Manage your linked accounts',
|
||||
module: 'Profile',
|
||||
icon: 'albums-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['linked accounts', 'manage accounts'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'AccountManagement' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-add-account',
|
||||
title: 'Add an account',
|
||||
subtitle: 'Link a new account',
|
||||
module: 'Profile',
|
||||
icon: 'add-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['new account', 'link account', 'open account'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'AddAccount' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-change-pin',
|
||||
title: 'Change PIN',
|
||||
subtitle: 'Update your login PIN',
|
||||
module: 'Profile',
|
||||
icon: 'keypad-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['pin', 'reset pin', 'mpin'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'ChangePin' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-change-password',
|
||||
title: 'Change password',
|
||||
subtitle: 'Update your account password',
|
||||
module: 'Profile',
|
||||
icon: 'lock-closed-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['password', 'reset password'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'ChangePassword' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-security',
|
||||
title: 'Security settings',
|
||||
subtitle: 'Biometrics and account security',
|
||||
module: 'Profile',
|
||||
icon: 'finger-print-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['biometric', 'fingerprint', 'face id', 'security'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'SecuritySettings' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-privacy',
|
||||
title: 'Privacy settings',
|
||||
subtitle: 'Control your data and privacy',
|
||||
module: 'Profile',
|
||||
icon: 'eye-off-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['privacy', 'data', 'permissions'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'PrivacySettings' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-notifications',
|
||||
title: 'Notification settings',
|
||||
subtitle: 'Manage alerts and notifications',
|
||||
module: 'Profile',
|
||||
icon: 'notifications-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['alerts', 'notifications', 'reminders'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'NotificationSettings' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-language',
|
||||
title: 'Language settings',
|
||||
subtitle: 'Change the app language',
|
||||
module: 'Profile',
|
||||
icon: 'language-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['language', 'locale', 'translate'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'LanguageSettings' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-help',
|
||||
title: 'Help & support',
|
||||
subtitle: 'Get help and contact us',
|
||||
module: 'Profile',
|
||||
icon: 'help-buoy-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['help', 'support', 'contact', 'faq'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'HelpSupport' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-legal',
|
||||
title: 'Legal information',
|
||||
subtitle: 'Terms, policies and disclosures',
|
||||
module: 'Profile',
|
||||
icon: 'document-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['terms', 'privacy policy', 'legal', 'conditions'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'LegalInfo' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-about',
|
||||
title: 'About this app',
|
||||
subtitle: 'App version and information',
|
||||
module: 'Profile',
|
||||
icon: 'information-circle-outline',
|
||||
iconColor: MODULE_COLORS.profile,
|
||||
keywords: ['version', 'about', 'app info'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'AboutApp' }),
|
||||
},
|
||||
];
|
||||
|
||||
// Account-specific destinations require an account to deep-link into.
|
||||
if (primaryAccountId) {
|
||||
features.push(
|
||||
{
|
||||
id: 'accounts-mini-statement',
|
||||
title: 'Mini statement',
|
||||
subtitle: 'Recent transactions on your account',
|
||||
module: 'Accounts',
|
||||
icon: 'list-outline',
|
||||
iconColor: MODULE_COLORS.accounts,
|
||||
keywords: ['transactions', 'recent', 'statement', 'history'],
|
||||
navigate: (nav) =>
|
||||
nav.navigate('Home', {
|
||||
screen: 'MiniStatement',
|
||||
params: { accountId: primaryAccountId },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'accounts-details',
|
||||
title: 'Account details',
|
||||
subtitle: 'Your account number and information',
|
||||
module: 'Accounts',
|
||||
icon: 'information-circle-outline',
|
||||
iconColor: MODULE_COLORS.accounts,
|
||||
keywords: ['account number', 'ifsc', 'details', 'branch'],
|
||||
navigate: (nav) =>
|
||||
nav.navigate('Home', {
|
||||
screen: 'AccountDetails',
|
||||
params: { accountId: primaryAccountId },
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters and ranks features for a query. An empty query returns the full
|
||||
* catalog (so the screen doubles as a browsable directory). Every whitespace-
|
||||
* separated token must appear somewhere in the feature's searchable text;
|
||||
* matches in the title (and especially title prefixes) rank highest.
|
||||
*/
|
||||
export function filterFeatures(
|
||||
features: SearchableFeature[],
|
||||
rawQuery: string,
|
||||
): SearchableFeature[] {
|
||||
const query = rawQuery.trim().toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
return features;
|
||||
}
|
||||
|
||||
const tokens = query.split(/\s+/);
|
||||
|
||||
const scored = features
|
||||
.map((feature) => {
|
||||
const title = feature.title.toLowerCase();
|
||||
const haystack = [feature.title, feature.subtitle, feature.module, ...feature.keywords]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
if (!tokens.every((token) => haystack.includes(token))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
if (title.startsWith(query)) {
|
||||
score += 100;
|
||||
} else if (title.includes(query)) {
|
||||
score += 60;
|
||||
}
|
||||
if (feature.keywords.some((keyword) => keyword.toLowerCase().startsWith(query))) {
|
||||
score += 30;
|
||||
}
|
||||
score += tokens.filter((token) => title.includes(token)).length * 8;
|
||||
|
||||
return { feature, score };
|
||||
})
|
||||
.filter((entry): entry is { feature: SearchableFeature; score: number } => entry !== null);
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
return scored.map((entry) => entry.feature);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export default function CertificateListScreen({ route }: Props) {
|
||||
}));
|
||||
|
||||
const handleDownload = (docName: string) => {
|
||||
toast('Download Started', 'success', `${docName} is being securely downloaded to your device.`);
|
||||
toast('Download started', 'success', `${docName} is being saved securely to your device.`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function ChequeBookRequestScreen({ navigation }: Props) {
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Request Submitted', 'success', 'Your cheque book will be delivered in 3-5 days.');
|
||||
toast('Request submitted', 'success', 'Your cheque book will arrive in 3 to 5 days.');
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
@@ -18,13 +18,13 @@ export default function Form15G15HScreen({ navigation }: Props) {
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!panNumber || !estimatedIncome) {
|
||||
toast('Validation Failed', 'error', 'PAN and estimated income are required.');
|
||||
toast('Some details are missing', 'error', 'Please enter your PAN and estimated income to continue.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Form Submitted successfully', 'success', `Your Form ${formType} has been recorded for the current FY.`);
|
||||
toast('Form submitted', 'success', `Your Form ${formType} has been recorded for the current financial year.`);
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
};
|
||||
@@ -78,7 +78,7 @@ export default function Form15G15HScreen({ navigation }: Props) {
|
||||
<Text style={{ fontWeight: '700', color: theme.colors.textPrimary }}>Form 15G - FY 24-25</Text>
|
||||
<Text style={{ fontSize: 12, color: theme.colors.textSecondary }}>Submitted April 2025</Text>
|
||||
</View>
|
||||
<Button title="Download" variant="ghost" onPress={() => toast('Downloading...', 'info')} />
|
||||
<Button title="Download" variant="ghost" onPress={() => toast('Download started', 'info', 'Your form is being prepared for download.')} />
|
||||
</Card>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
|
||||
@@ -20,13 +20,13 @@ export default function PositivePayScreen({ navigation }: Props) {
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!chequeNumber || !amount || !payee) {
|
||||
toast('Incomplete Form', 'error', 'Please fill all required details.');
|
||||
toast('Some details are missing', 'error', 'Please fill in all the required details to continue.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Verification Submitted', 'success', `Details for Cheque #${chequeNumber} saved securely.`);
|
||||
toast('Cheque details submitted', 'success', `Your details for cheque #${chequeNumber} have been saved securely.`);
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
@@ -80,9 +80,9 @@ export default function ServicesHubScreen({ navigation }: Props) {
|
||||
iconColor: '#0EA5E9',
|
||||
onPress: () =>
|
||||
toast(
|
||||
'Support chat screen pending',
|
||||
'Support chat coming soon',
|
||||
'info',
|
||||
'This feature is coming in the next update.',
|
||||
'This feature will be available in the next update.',
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -91,7 +91,7 @@ export default function ServicesHubScreen({ navigation }: Props) {
|
||||
icon: 'help-circle-outline',
|
||||
iconColor: '#8B5CF6',
|
||||
onPress: () =>
|
||||
toast('Help center pending', 'info', 'Coming soon.'),
|
||||
toast('Help center coming soon', 'info', 'This feature will be available shortly.'),
|
||||
},
|
||||
{
|
||||
id: 'cyber_security',
|
||||
|
||||
@@ -17,13 +17,13 @@ export default function SpecialInstructionsScreen({ navigation }: Props) {
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!title || !description) {
|
||||
toast('Empty fields', 'error', 'Title and description are required.');
|
||||
toast('Some details are missing', 'error', 'Please add a title and description before you continue.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Request Logged', 'success', 'Your special instruction has been submitted to the branch.');
|
||||
toast('Request submitted', 'success', 'Your special instruction has been sent to your branch.');
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function StopPaymentScreen({ navigation }: Props) {
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Payment Stopped', 'success', `Cheque #${chequeNumber} has been blocked.`);
|
||||
toast('Payment stopped', 'success', `We've stopped payment on cheque #${chequeNumber}.`);
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import HomeScreen from '../modules/home/screens/HomeScreen';
|
||||
import GlobalSearchScreen from '../modules/search/screens/GlobalSearchScreen';
|
||||
import NotificationsScreen from '../modules/notifications/screens/NotificationsScreen';
|
||||
import MiniStatementScreen from '../modules/home/screens/MiniStatementScreen';
|
||||
import AccountDetailsScreen from '../modules/home/screens/AccountDetailsScreen';
|
||||
import ReKycScreen from '../modules/home/screens/ReKycScreen';
|
||||
@@ -14,6 +16,8 @@ export const HomeNavigator = () => {
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="HomeMain" component={HomeScreen} />
|
||||
<Stack.Screen name="GlobalSearch" component={GlobalSearchScreen} />
|
||||
<Stack.Screen name="Notifications" component={NotificationsScreen} />
|
||||
<Stack.Screen name="MiniStatement" component={MiniStatementScreen} />
|
||||
<Stack.Screen name="AccountDetails" component={AccountDetailsScreen} />
|
||||
<Stack.Screen name="ReKyc" component={ReKycScreen} />
|
||||
|
||||
@@ -20,8 +20,6 @@ export const RootNavigator = () => {
|
||||
|
||||
const renderAppStack = () => {
|
||||
switch (role) {
|
||||
case 'Employee':
|
||||
return <EmployeeNavigator />;
|
||||
case 'Agent':
|
||||
return <AgentNavigator />;
|
||||
case 'User':
|
||||
|
||||
@@ -22,6 +22,8 @@ export type LoanStackParamList = {
|
||||
|
||||
export type HomeStackParamList = {
|
||||
HomeMain: undefined;
|
||||
GlobalSearch: undefined;
|
||||
Notifications: undefined;
|
||||
MiniStatement: { accountId: string };
|
||||
AccountDetails: { accountId: string };
|
||||
ReKyc: { accountId: string };
|
||||
|
||||
@@ -1,9 +1,130 @@
|
||||
import { PermissionsAndroid, Platform } from 'react-native';
|
||||
import notifee, {
|
||||
AndroidImportance,
|
||||
AuthorizationStatus,
|
||||
TriggerType,
|
||||
type TimestampTrigger,
|
||||
} from '@notifee/react-native';
|
||||
|
||||
export const registerForPushNotifications = async () => {
|
||||
if (Platform.OS === 'android' && Platform.Version >= 33) {
|
||||
return PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
|
||||
}
|
||||
import type { NotificationCategory } from '../../types/app';
|
||||
|
||||
return 'granted';
|
||||
const CHANNEL_ID = 'bankcbs-general';
|
||||
|
||||
export type DummyNotificationContent = {
|
||||
category: NotificationCategory;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
const DUMMY_POOL: DummyNotificationContent[] = [
|
||||
{
|
||||
category: 'transaction',
|
||||
title: 'Money received',
|
||||
body: '₹4,200 was credited to your Savings account from Rohan Mehta.',
|
||||
},
|
||||
{
|
||||
category: 'transaction',
|
||||
title: 'Payment successful',
|
||||
body: 'Your payment of ₹1,150 for a mobile recharge went through.',
|
||||
},
|
||||
{
|
||||
category: 'security',
|
||||
title: 'New sign-in detected',
|
||||
body: 'A new sign-in was detected on your account. Tap to review.',
|
||||
},
|
||||
{
|
||||
category: 'offer',
|
||||
title: 'Reward unlocked',
|
||||
body: 'You just earned 250 reward points on your last card spend.',
|
||||
},
|
||||
{
|
||||
category: 'reminder',
|
||||
title: 'Bill reminder',
|
||||
body: 'Your electricity bill of ₹2,340 is due in 3 days.',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_CONTENT: DummyNotificationContent = DUMMY_POOL[0] ?? {
|
||||
category: 'system',
|
||||
title: 'New notification',
|
||||
body: 'You have a new update.',
|
||||
};
|
||||
|
||||
export const getDummyNotificationContent = (): DummyNotificationContent => {
|
||||
const index = Math.floor(Math.random() * DUMMY_POOL.length);
|
||||
return DUMMY_POOL[index] ?? DEFAULT_CONTENT;
|
||||
};
|
||||
|
||||
let channelReady = false;
|
||||
|
||||
const ensureChannel = async () => {
|
||||
if (!channelReady) {
|
||||
await notifee.createChannel({
|
||||
id: CHANNEL_ID,
|
||||
name: 'General alerts',
|
||||
importance: AndroidImportance.HIGH,
|
||||
});
|
||||
channelReady = true;
|
||||
}
|
||||
return CHANNEL_ID;
|
||||
};
|
||||
|
||||
/** Request OS notification permission and make sure the Android channel exists. */
|
||||
export const registerForPushNotifications = async () => {
|
||||
const settings = await notifee.requestPermission();
|
||||
await ensureChannel();
|
||||
|
||||
const granted =
|
||||
settings.authorizationStatus === AuthorizationStatus.AUTHORIZED ||
|
||||
settings.authorizationStatus === AuthorizationStatus.PROVISIONAL;
|
||||
|
||||
return granted ? 'granted' : 'denied';
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a notification immediately. While the app is in the foreground this
|
||||
* appears as a heads-up popup (HIGH importance channel); it also lands in the
|
||||
* system tray.
|
||||
*/
|
||||
export const displayLocalNotification = async (content: DummyNotificationContent) => {
|
||||
const channelId = await ensureChannel();
|
||||
await notifee.displayNotification({
|
||||
title: content.title,
|
||||
body: content.body,
|
||||
android: {
|
||||
channelId,
|
||||
smallIcon: 'ic_launcher',
|
||||
importance: AndroidImportance.HIGH,
|
||||
pressAction: { id: 'default' },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedule a notification to fire after `seconds`. This is how we demo a
|
||||
* background notification: trigger it, then minimise the app and it arrives in
|
||||
* the system tray even though the app is no longer in the foreground.
|
||||
*/
|
||||
export const scheduleLocalNotification = async (
|
||||
content: DummyNotificationContent,
|
||||
seconds = 5,
|
||||
) => {
|
||||
const channelId = await ensureChannel();
|
||||
const trigger: TimestampTrigger = {
|
||||
type: TriggerType.TIMESTAMP,
|
||||
timestamp: Date.now() + seconds * 1000,
|
||||
};
|
||||
|
||||
await notifee.createTriggerNotification(
|
||||
{
|
||||
title: content.title,
|
||||
body: content.body,
|
||||
android: {
|
||||
channelId,
|
||||
smallIcon: 'ic_launcher',
|
||||
importance: AndroidImportance.HIGH,
|
||||
pressAction: { id: 'default' },
|
||||
},
|
||||
},
|
||||
trigger,
|
||||
);
|
||||
};
|
||||
|
||||
+18
-1
@@ -27,7 +27,7 @@ export type CardDeliveryStage =
|
||||
| 'ready-for-pickup'
|
||||
| 'delivered';
|
||||
|
||||
export type LoginRole = 'User' | 'Employee' | 'Agent';
|
||||
export type LoginRole = 'User' | 'Agent';
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
@@ -57,6 +57,23 @@ export interface UserProfile {
|
||||
notifications: NotificationPreference;
|
||||
}
|
||||
|
||||
export type NotificationCategory =
|
||||
| 'transaction'
|
||||
| 'security'
|
||||
| 'offer'
|
||||
| 'reminder'
|
||||
| 'system';
|
||||
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
category: NotificationCategory;
|
||||
title: string;
|
||||
body: string;
|
||||
/** ISO timestamp of when the notification was raised. */
|
||||
createdAt: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export interface AccountSummary {
|
||||
id: string;
|
||||
type: AccountType;
|
||||
|
||||
Reference in New Issue
Block a user