Fix form keyboard issues and sticky footers in agent screens

This commit is contained in:
Abin Toppo
2026-05-26 16:34:05 +05:30
commit 9ac5e95d52
410 changed files with 86650 additions and 0 deletions
@@ -0,0 +1,294 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
ScrollView,
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 { useAppSelector } from '../../../app/hooks';
import { Card, Input, ScreenShell } from '../../../components';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCurrency } from '../../../utils/formatters';
import { DepositOptionChip } from '../components/DepositOptionChip';
import {
selectFixedDepositProduct,
selectRecurringDepositProduct,
} from '../store/depositSelectors';
import {
calculateFixedDepositMaturity,
calculateRecurringDepositMaturity,
} from '../utils/depositCalculators';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'DepositCalculator'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
type CalculatorMode = 'fd' | 'rd';
type TenureUnit = 'months' | 'years';
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d.]/g, ''));
export default function DepositCalculatorScreen({ route }: Props) {
const theme = useTheme();
const fixedProduct = useAppSelector(selectFixedDepositProduct);
const recurringProduct = useAppSelector(selectRecurringDepositProduct);
const [mode, setMode] = useState<CalculatorMode>(route.params?.initialKind ?? 'fd');
const [amount, setAmount] = useState('100000');
const [monthlyInstallment, setMonthlyInstallment] = useState('5000');
const [rate, setRate] = useState(String(fixedProduct?.rate ?? 7.1));
const [tenureValue, setTenureValue] = useState('12');
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
useEffect(() => {
if (route.params?.initialKind) {
setMode(route.params.initialKind);
}
}, [route.params?.initialKind]);
useEffect(() => {
const nextRate =
mode === 'fd' ? fixedProduct?.rate ?? 7.1 : recurringProduct?.rate ?? 6.8;
setRate(String(nextRate));
}, [fixedProduct?.rate, mode, recurringProduct?.rate]);
const tenureMonths = useMemo(() => {
const numericValue = toPositiveNumber(tenureValue);
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
}, [tenureUnit, tenureValue]);
const amountNumber = useMemo(() => toPositiveNumber(amount), [amount]);
const monthlyInstallmentNumber = useMemo(
() => toPositiveNumber(monthlyInstallment),
[monthlyInstallment],
);
const rateNumber = useMemo(() => Number(rate), [rate]);
const maturityAmount = useMemo(
() =>
mode === 'fd'
? calculateFixedDepositMaturity(amountNumber, rateNumber, tenureMonths, 'cumulative')
: calculateRecurringDepositMaturity(
monthlyInstallmentNumber,
rateNumber,
tenureMonths,
),
[amountNumber, mode, monthlyInstallmentNumber, rateNumber, tenureMonths],
);
const totalInvestment =
mode === 'fd' ? amountNumber : monthlyInstallmentNumber * tenureMonths;
return (
<ScreenShell
title="Deposit calculator"
subtitle="Compare projected returns for fixed and recurring deposits"
scrollable={false}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
>
<Card style={styles.formCard}>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
Calculator type
</Text>
<View style={styles.optionRow}>
<DepositOptionChip
label="Fixed deposit"
selected={mode === 'fd'}
onPress={() => setMode('fd')}
/>
<DepositOptionChip
label="Recurring deposit"
selected={mode === 'rd'}
onPress={() => setMode('rd')}
/>
</View>
</View>
{mode === 'fd' ? (
<Input
label="Principal amount"
value={amount}
onChangeText={setAmount}
keyboardType="number-pad"
placeholder="100000"
/>
) : (
<Input
label="Monthly deposit"
value={monthlyInstallment}
onChangeText={setMonthlyInstallment}
keyboardType="number-pad"
placeholder="5000"
/>
)}
<Input
label="Interest rate (%)"
value={rate}
onChangeText={setRate}
keyboardType="decimal-pad"
placeholder="7.1"
/>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
Tenure
</Text>
<Input
value={tenureValue}
onChangeText={setTenureValue}
keyboardType="number-pad"
placeholder="12"
/>
<View style={styles.optionRow}>
{TENURE_UNITS.map((unit) => (
<DepositOptionChip
key={unit}
label={unit === 'months' ? 'Months' : 'Years'}
selected={tenureUnit === unit}
onPress={() => setTenureUnit(unit)}
/>
))}
</View>
</View>
</Card>
<Card style={styles.resultCard}>
<View style={styles.resultHeader}>
<View style={[styles.resultIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="calculator-outline" size={20} color={theme.colors.primary} />
</View>
<View style={styles.resultCopy}>
<Text style={[styles.resultTitle, { color: theme.colors.textPrimary }]}>
Estimated maturity
</Text>
<Text style={[styles.resultSubtitle, { color: theme.colors.textSecondary }]}>
Based on the rate and tenure entered above
</Text>
</View>
</View>
<Text style={[styles.resultValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.round(maturityAmount))}
</Text>
<View style={styles.resultGrid}>
<View style={[styles.resultTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>
Total contribution
</Text>
<Text style={[styles.resultTileValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(totalInvestment || 0)}
</Text>
</View>
<View style={[styles.resultTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>
Estimated gains
</Text>
<Text style={[styles.resultTileValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.max(Math.round(maturityAmount) - totalInvestment, 0))}
</Text>
</View>
</View>
</Card>
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
keyboardAvoider: {
flex: 1,
},
scrollContent: {
gap: 18,
paddingBottom: 24,
},
formCard: {
gap: 18,
},
fieldGroup: {
gap: 10,
},
fieldLabel: {
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
optionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
resultCard: {
gap: 18,
},
resultHeader: {
flexDirection: 'row',
gap: 12,
alignItems: 'center',
},
resultIcon: {
width: 44,
height: 44,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
resultCopy: {
flex: 1,
gap: 4,
},
resultTitle: {
fontSize: 18,
fontWeight: '800',
},
resultSubtitle: {
fontSize: 13,
lineHeight: 18,
},
resultValue: {
fontSize: 30,
fontWeight: '900',
letterSpacing: -0.8,
},
resultGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
resultTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
resultLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
resultTileValue: {
fontSize: 15,
fontWeight: '800',
lineHeight: 20,
},
});
@@ -0,0 +1,565 @@
import React, { useMemo, useState } from 'react';
import {
ScrollView,
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 { AppModal, Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCurrency, formatDate } from '../../../utils/formatters';
import { TransactionItem } from '../../cards/components/TransactionItem';
import { ExpandableSection } from '../../cards/components/ExpandableSection';
import { DepositStatusPill } from '../components/DepositStatusPill';
import {
useCloseDepositMutation,
useGetDepositDetailQuery,
} from '../services/depositApi';
import {
calculatePrematureClosureAmount,
getDaysUntilMaturity,
getDepositProgress,
isDepositNearMaturity,
} from '../utils/depositCalculators';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'DepositDetails'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
export default function DepositDetailsScreen({ navigation, route }: Props) {
const theme = useTheme();
const toast = useToast();
const [confirmVisible, setConfirmVisible] = useState(false);
const { data: deposit, isLoading, isError, refetch } = useGetDepositDetailQuery({
depositId: route.params.depositId,
kind: route.params.kind,
});
const [closeDeposit, closeState] = useCloseDepositMutation();
const nearMaturity = deposit ? isDepositNearMaturity(deposit) : false;
const closureQuote = deposit ? calculatePrematureClosureAmount(deposit) : null;
const progress = deposit
? getDepositProgress(deposit.startDate, deposit.maturityDate)
: 0;
const daysUntilMaturity = deposit
? Math.max(getDaysUntilMaturity(deposit.maturityDate), 0)
: 0;
const history = useMemo(
() =>
(deposit?.transactions ?? []).map((transaction) => ({
id: transaction.id,
merchant: transaction.title,
amount: transaction.amount,
direction: transaction.direction,
status: transaction.status,
occurredAt: transaction.occurredAt,
category: transaction.category,
})),
[deposit?.transactions],
);
const handleCloseDeposit = async () => {
if (!deposit) {
return;
}
try {
await closeDeposit({
depositId: deposit.id,
kind: deposit.kind,
}).unwrap();
setConfirmVisible(false);
toast(
deposit.status === 'matured' ? 'Maturity proceeds credited' : 'Deposit closed',
'success',
deposit.status === 'matured'
? 'The maturity amount has been credited to your linked account.'
: 'Closure request completed successfully.',
);
} catch {
toast(
'Unable to close deposit',
'error',
'Please try again in a moment.',
);
}
};
if (isLoading) {
return (
<ScreenShell title="Deposit details" subtitle="Loading selected deposit">
<Card>
<Loader />
</Card>
</ScreenShell>
);
}
if (isError || !deposit) {
return (
<ScreenShell title="Deposit details" subtitle="We could not load the selected deposit">
<EmptyState
title="Deposit unavailable"
description="Pull back and try opening the deposit again."
actionLabel="Retry"
onAction={refetch}
/>
</ScreenShell>
);
}
const canClose = deposit.status !== 'closed' && deposit.canWithdrawPrematurely;
const closeActionLabel =
deposit.status === 'matured' ? 'Credit maturity proceeds' : 'Premature close';
const investedAmount =
deposit.kind === 'fd'
? deposit.principalAmount
: deposit.totalInvestment;
return (
<ScreenShell
title="Deposit details"
subtitle={`${deposit.maskedNumber} · ${deposit.type}`}
scrollable={false}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<Card style={styles.heroCard}>
<View style={styles.heroTopRow}>
<View style={styles.heroCopy}>
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
{deposit.type}
</Text>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
{deposit.maskedNumber}
</Text>
</View>
<DepositStatusPill status={deposit.status} nearMaturity={nearMaturity} />
</View>
<View style={styles.heroMetrics}>
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Invested amount
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(investedAmount)}
</Text>
</View>
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Maturity value
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(deposit.maturityAmount)}
</Text>
</View>
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Interest earned
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(deposit.interestEarned)}
</Text>
</View>
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Tracking
</Text>
<Text
style={[
styles.heroMetricValue,
{
color:
deposit.status === 'closed'
? theme.colors.textSecondary
: nearMaturity
? theme.colors.warning
: theme.colors.textPrimary,
},
]}
>
{deposit.status === 'closed'
? 'Closed'
: deposit.status === 'matured'
? 'Ready'
: `${daysUntilMaturity} days left`}
</Text>
</View>
</View>
<View style={styles.progressWrap}>
<View style={[styles.progressTrack, { backgroundColor: theme.colors.surfaceMuted }]}>
<View
style={[
styles.progressFill,
{
width: `${progress * 100}%`,
backgroundColor:
deposit.status === 'closed'
? theme.colors.textMuted
: nearMaturity
? theme.colors.warning
: theme.colors.accent,
},
]}
/>
</View>
<Text style={[styles.progressCaption, { color: theme.colors.textSecondary }]}>
Started on {formatDate(deposit.startDate)} · Matures on {formatDate(deposit.maturityDate)}
</Text>
</View>
</Card>
<ExpandableSection
title="Deposit snapshot"
subtitle="Principal, interest, payout, nominee, and settlement account"
icon="document-text-outline"
defaultExpanded
>
<View style={styles.detailGrid}>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Interest rate
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.interestRate}% p.a.
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Tenure
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.tenureMonths} months
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Payout frequency
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.payoutLabel}
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Settlement account
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.settlementAccountMasked}
</Text>
</View>
{deposit.nominee ? (
<View style={[styles.detailTile, styles.detailTileWide, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Nominee
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.nominee.name} · {deposit.nominee.relationship}
</Text>
</View>
) : null}
</View>
</ExpandableSection>
<ExpandableSection
title="Returns and maturity"
subtitle="Projected value, accrued gains, and current closure estimate"
icon="analytics-outline"
defaultExpanded
>
<View style={styles.detailGrid}>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Total investment
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(deposit.totalInvestment)}
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Estimated proceeds
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(closureQuote?.netAmount ?? deposit.maturityAmount)}
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Penalty
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(closureQuote?.penaltyAmount ?? 0)}
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Maturity date
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{formatDate(deposit.maturityDate)}
</Text>
</View>
</View>
</ExpandableSection>
<ExpandableSection
title="Transaction history"
subtitle="Creation, installments, interest payouts, and closure credits"
icon="swap-horizontal-outline"
badge={`${history.length} entries`}
>
{history.length ? (
history.map((item, index) => (
<TransactionItem
key={item.id}
item={item}
showDivider={index !== history.length - 1}
/>
))
) : (
<EmptyState
title="No transactions yet"
description="Transaction history will appear here after the deposit is created."
/>
)}
</ExpandableSection>
<Card style={styles.actionsCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Actions
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Estimate new returns or close the deposit if you need funds earlier
</Text>
<Button
title="Open calculator"
variant="secondary"
onPress={() =>
navigation.navigate('DepositCalculator', { initialKind: deposit.kind })
}
leftAccessory={<Ionicons name="calculator-outline" size={18} color={theme.colors.textPrimary} />}
/>
{canClose ? (
<Button
title={closeActionLabel}
variant={deposit.status === 'matured' ? 'primary' : 'danger'}
loading={closeState.isLoading}
onPress={() => setConfirmVisible(true)}
leftAccessory={
<Ionicons
name={deposit.status === 'matured' ? 'cash-outline' : 'close-circle-outline'}
size={18}
color={theme.colors.white}
/>
}
/>
) : null}
</Card>
</ScrollView>
<AppModal
visible={confirmVisible}
title={closeActionLabel}
description={
deposit.status === 'matured'
? 'The maturity amount will be credited to your linked settlement account.'
: 'This will close the deposit before maturity and credit the net proceeds after penalty.'
}
onClose={() => setConfirmVisible(false)}
>
<View style={styles.modalList}>
<View style={styles.modalRow}>
<Text style={[styles.modalLabel, { color: theme.colors.textSecondary }]}>
Net proceeds
</Text>
<Text style={[styles.modalValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(closureQuote?.netAmount ?? deposit.maturityAmount)}
</Text>
</View>
<View style={styles.modalRow}>
<Text style={[styles.modalLabel, { color: theme.colors.textSecondary }]}>
Penalty
</Text>
<Text style={[styles.modalValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(closureQuote?.penaltyAmount ?? 0)}
</Text>
</View>
<View style={styles.modalRow}>
<Text style={[styles.modalLabel, { color: theme.colors.textSecondary }]}>
Credit account
</Text>
<Text style={[styles.modalValue, { color: theme.colors.textPrimary }]}>
{deposit.settlementAccountMasked}
</Text>
</View>
</View>
<View style={styles.modalActions}>
<Button
title="Cancel"
variant="secondary"
onPress={() => setConfirmVisible(false)}
style={styles.modalButton}
fullWidth={false}
/>
<Button
title="Confirm"
variant={deposit.status === 'matured' ? 'primary' : 'danger'}
onPress={handleCloseDeposit}
loading={closeState.isLoading}
style={styles.modalButton}
fullWidth={false}
/>
</View>
</AppModal>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
gap: 18,
paddingBottom: 40,
},
heroCard: {
gap: 18,
},
heroTopRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
heroCopy: {
flex: 1,
gap: 4,
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
fontSize: 24,
fontWeight: '900',
letterSpacing: -0.6,
},
heroMetrics: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
heroMetricTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
heroMetricLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroMetricValue: {
fontSize: 16,
fontWeight: '900',
lineHeight: 22,
},
progressWrap: {
gap: 10,
},
progressTrack: {
height: 12,
borderRadius: 999,
overflow: 'hidden',
},
progressFill: {
height: 12,
borderRadius: 999,
},
progressCaption: {
fontSize: 12,
fontWeight: '500',
},
detailGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
detailTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
detailTileWide: {
width: '100%',
},
detailLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
detailValue: {
fontSize: 14,
fontWeight: '800',
lineHeight: 20,
},
actionsCard: {
gap: 12,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
},
sectionSubtitle: {
fontSize: 13,
lineHeight: 18,
},
modalList: {
gap: 10,
marginTop: 4,
},
modalRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
modalLabel: {
fontSize: 13,
fontWeight: '500',
},
modalValue: {
fontSize: 13,
fontWeight: '800',
textAlign: 'right',
flex: 1,
},
modalActions: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
modalButton: {
flex: 1,
},
});
@@ -0,0 +1,650 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
RefreshControl,
ScrollView,
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 { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
import { DepositAccountCard } from '../components/DepositAccountCard';
import { DepositOptionChip } from '../components/DepositOptionChip';
import { useDepositPortfolio } from '../hooks/useDepositPortfolio';
import { getDaysUntilMaturity, isDepositNearMaturity } from '../utils/depositCalculators';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'DepositsOverview'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
type FilterKey = 'all' | 'fd' | 'rd' | 'maturing';
const FILTERS: Array<{ id: FilterKey; label: string }> = [
{ id: 'all', label: 'All deposits' },
{ id: 'fd', label: 'Fixed deposit' },
{ id: 'rd', label: 'Recurring deposit' },
{ id: 'maturing', label: 'Maturing soon' },
];
export default function DepositsOverviewScreen({ navigation, route }: Props) {
const theme = useTheme();
const { deposits, isLoading, isError, onRefresh, refreshing } = useDepositPortfolio();
const [filter, setFilter] = useState<FilterKey>(route.params?.initialKind ?? 'all');
useEffect(() => {
if (route.params?.initialKind) {
setFilter(route.params.initialKind);
}
}, [route.params?.initialKind]);
const summary = useMemo(() => {
const activeDeposits = deposits.filter((deposit) => deposit.status === 'active');
const nearMaturityDeposits = deposits.filter((deposit) => isDepositNearMaturity(deposit));
return {
totalDeposits: deposits.length,
activeDepositsCount: activeDeposits.length,
totalActiveValue: activeDeposits.reduce(
(sum, deposit) => sum + deposit.totalInvestment,
0,
),
projectedMaturityValue: deposits.reduce(
(sum, deposit) => sum + deposit.maturityAmount,
0,
),
nearMaturityDeposits,
};
}, [deposits]);
const filteredDeposits = useMemo(() => {
switch (filter) {
case 'fd':
return deposits.filter((deposit) => deposit.kind === 'fd');
case 'rd':
return deposits.filter((deposit) => deposit.kind === 'rd');
case 'maturing':
return deposits.filter((deposit) => isDepositNearMaturity(deposit));
case 'all':
default:
return deposits;
}
}, [deposits, filter]);
const selectedFilterLabel =
FILTERS.find((item) => item.id === filter)?.label ?? 'All deposits';
const handleOpenFixedDeposit = () => navigation.navigate('OpenFixedDeposit');
const handleOpenRecurringDeposit = () => navigation.navigate('OpenRecurringDeposit');
const handleOpenCalculator = () => navigation.navigate('DepositCalculator');
return (
<ScreenShell
title="Deposits"
subtitle="Fixed and recurring deposits, maturity tracking, and calculators"
scrollable={false}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}
>
<View style={styles.summaryGrid}>
<Card style={styles.heroCard}>
<View style={styles.heroHeader}>
<View
style={[
styles.heroIcon,
{ backgroundColor: `${theme.colors.primary}12` },
]}
>
<Ionicons name="wallet-outline" size={20} color={theme.colors.primary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroEyebrow, { color: theme.colors.primary }]}>
Portfolio snapshot
</Text>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
Keep every deposit journey in one clear view
</Text>
<Text style={[styles.heroSubtitle, { color: theme.colors.textSecondary }]}>
Review portfolio value, upcoming maturities, and quick actions without
jumping across sections.
</Text>
</View>
</View>
<View
style={[
styles.heroHighlight,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<View style={styles.heroHighlightCopy}>
<Text
style={[
styles.heroHighlightLabel,
{ color: theme.colors.textSecondary },
]}
>
Active portfolio
</Text>
<Text
style={[
styles.heroHighlightValue,
{ color: theme.colors.textPrimary },
]}
>
{formatCompactCurrency(summary.totalActiveValue)}
</Text>
<Text
style={[
styles.heroHighlightCaption,
{ color: theme.colors.textMuted },
]}
>
{summary.activeDepositsCount} active plans across fixed and recurring
deposits
</Text>
</View>
<View
style={[
styles.heroBadge,
{ backgroundColor: `${theme.colors.warning}14` },
]}
>
<Text style={[styles.heroBadgeValue, { color: theme.colors.warning }]}>
{summary.nearMaturityDeposits.length}
</Text>
<Text
style={[
styles.heroBadgeLabel,
{ color: theme.colors.textSecondary },
]}
>
maturing soon
</Text>
</View>
</View>
<View style={styles.heroMetrics}>
<View
style={[
styles.heroMetricCard,
{ backgroundColor: `${theme.colors.primary}08` },
]}
>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Projected maturity
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{formatCompactCurrency(summary.projectedMaturityValue)}
</Text>
<Text style={[styles.heroMetricCaption, { color: theme.colors.textMuted }]}>
Estimated portfolio value when all plans mature
</Text>
</View>
<View
style={[
styles.heroMetricCard,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Total deposits
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{summary.totalDeposits}
</Text>
<Text style={[styles.heroMetricCaption, { color: theme.colors.textMuted }]}>
{summary.totalDeposits === 1 ? '1 deposit' : `${summary.totalDeposits} deposits`} currently tracked
</Text>
</View>
</View>
</Card>
</View>
<Card style={styles.actionCard}>
<View style={styles.sectionHeader}>
<View>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Quick actions
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Start a new plan or estimate returns from one place
</Text>
</View>
</View>
<View style={styles.actionGrid}>
<View style={styles.actionRow}>
<View style={styles.actionColumn}>
<Button
title="Open FD"
style={styles.actionButton}
onPress={handleOpenFixedDeposit}
leftAccessory={
<Ionicons
name="trending-up-outline"
size={18}
color={theme.colors.white}
/>
}
/>
</View>
<View style={styles.actionColumn}>
<Button
title="Open RD"
variant="secondary"
style={styles.actionButton}
onPress={handleOpenRecurringDeposit}
leftAccessory={
<Ionicons
name="repeat-outline"
size={18}
color={theme.colors.textPrimary}
/>
}
/>
</View>
</View>
<Button
title="Calculator"
variant="secondary"
style={styles.actionButton}
onPress={handleOpenCalculator}
leftAccessory={
<Ionicons
name="calculator-outline"
size={18}
color={theme.colors.textPrimary}
/>
}
/>
</View>
</Card>
{summary.nearMaturityDeposits.length ? (
<Card style={styles.watchCard}>
<View style={styles.watchHeader}>
<View style={[styles.watchIcon, { backgroundColor: `${theme.colors.warning}16` }]}>
<Ionicons name="time-outline" size={18} color={theme.colors.warning} />
</View>
<View style={styles.watchCopy}>
<Text style={[styles.watchTitle, { color: theme.colors.textPrimary }]}>
Nearing maturity
</Text>
<Text style={[styles.watchSubtitle, { color: theme.colors.textSecondary }]}>
Review and plan your next action before these deposits mature
</Text>
</View>
</View>
{summary.nearMaturityDeposits.map((deposit) => (
<View
key={`${deposit.id}_watch`}
style={[styles.watchRow, { backgroundColor: theme.colors.surfaceMuted }]}
>
<View style={styles.watchRowCopy}>
<Text style={[styles.watchDeposit, { color: theme.colors.textPrimary }]}>
{deposit.maskedNumber}
</Text>
<Text style={[styles.watchMeta, { color: theme.colors.textSecondary }]}>
{deposit.type} · {formatCurrency(deposit.maturityAmount)}
</Text>
</View>
<Text
style={[
styles.watchDays,
{
color: theme.colors.warning,
backgroundColor: `${theme.colors.warning}12`,
},
]}
>
{Math.max(getDaysUntilMaturity(deposit.maturityDate), 0)} days
</Text>
</View>
))}
</Card>
) : null}
<View style={styles.portfolioSection}>
<View style={styles.portfolioHeader}>
<View style={styles.portfolioCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Your deposits
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
{filteredDeposits.length} shown in {selectedFilterLabel.toLowerCase()}
</Text>
</View>
<View
style={[
styles.portfolioCountBadge,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text
style={[styles.portfolioCountValue, { color: theme.colors.textPrimary }]}
>
{summary.totalDeposits}
</Text>
<Text
style={[styles.portfolioCountLabel, { color: theme.colors.textSecondary }]}
>
total
</Text>
</View>
</View>
<View style={styles.filterWrap}>
{FILTERS.map((item) => (
<DepositOptionChip
key={item.id}
label={item.label}
selected={filter === item.id}
onPress={() => setFilter(item.id)}
/>
))}
</View>
</View>
{isLoading && !deposits.length ? (
<Card>
<Loader />
</Card>
) : null}
{isError && !deposits.length ? (
<EmptyState
title="Unable to load deposits"
description="Please pull to refresh or try again in a moment."
actionLabel="Refresh"
onAction={onRefresh}
/>
) : null}
{!isLoading && !isError && !filteredDeposits.length ? (
<EmptyState
title="No deposits found"
description="Open a new fixed or recurring deposit to start building your savings ladder."
actionLabel="Open fixed deposit"
onAction={handleOpenFixedDeposit}
/>
) : null}
{filteredDeposits.map((deposit) => (
<DepositAccountCard
key={deposit.id}
deposit={deposit}
onPress={() =>
navigation.navigate('DepositDetails', {
depositId: deposit.id,
kind: deposit.kind,
})
}
/>
))}
<View style={styles.bottomSpacer} />
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
gap: 18,
paddingBottom: 24,
},
summaryGrid: {
gap: 12,
},
heroCard: {
gap: 16,
},
heroHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
heroIcon: {
width: 46,
height: 46,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
heroCopy: {
flex: 1,
gap: 4,
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
fontSize: 20,
fontWeight: '900',
lineHeight: 26,
},
heroSubtitle: {
fontSize: 12,
lineHeight: 18,
},
heroHighlight: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
borderRadius: 18,
padding: 16,
},
heroHighlightCopy: {
flex: 1,
gap: 4,
},
heroHighlightLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroHighlightValue: {
fontSize: 28,
fontWeight: '900',
letterSpacing: -0.8,
},
heroHighlightCaption: {
fontSize: 12,
lineHeight: 18,
},
heroBadge: {
minWidth: 88,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
paddingHorizontal: 14,
paddingVertical: 12,
},
heroBadgeValue: {
fontSize: 22,
fontWeight: '900',
letterSpacing: -0.5,
},
heroBadgeLabel: {
fontSize: 11,
fontWeight: '700',
textAlign: 'center',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
heroMetrics: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
heroMetricCard: {
flex: 1,
minWidth: 140,
borderRadius: 18,
padding: 14,
gap: 6,
},
heroMetricLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroMetricValue: {
fontSize: 20,
fontWeight: '900',
letterSpacing: -0.4,
},
heroMetricCaption: {
fontSize: 12,
lineHeight: 18,
},
actionCard: {
gap: 16,
},
sectionHeader: {
gap: 4,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
},
sectionSubtitle: {
fontSize: 13,
lineHeight: 18,
},
actionGrid: {
gap: 12,
},
actionRow: {
flexDirection: 'row',
gap: 12,
},
actionColumn: {
flex: 1,
},
actionButton: {
width: '100%',
},
watchCard: {
gap: 14,
},
watchHeader: {
flexDirection: 'row',
gap: 12,
},
watchIcon: {
width: 40,
height: 40,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
watchCopy: {
flex: 1,
gap: 4,
},
watchTitle: {
fontSize: 16,
fontWeight: '800',
},
watchSubtitle: {
fontSize: 13,
lineHeight: 18,
},
watchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
borderRadius: 18,
padding: 14,
},
watchRowCopy: {
flex: 1,
gap: 4,
},
watchDeposit: {
fontSize: 14,
fontWeight: '800',
},
watchMeta: {
fontSize: 12,
fontWeight: '500',
},
watchDays: {
fontSize: 13,
fontWeight: '800',
borderRadius: 999,
overflow: 'hidden',
paddingHorizontal: 10,
paddingVertical: 8,
},
portfolioSection: {
gap: 14,
},
portfolioHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
portfolioCopy: {
flex: 1,
gap: 4,
},
portfolioCountBadge: {
minWidth: 72,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
paddingHorizontal: 12,
paddingVertical: 10,
},
portfolioCountValue: {
fontSize: 20,
fontWeight: '900',
letterSpacing: -0.4,
},
portfolioCountLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
filterWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
bottomSpacer: {
height: 60,
},
});
@@ -0,0 +1,380 @@
import React, { useMemo, useState } from 'react';
import {
ScrollView,
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 { useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import type { DepositInterestPayoutType } from '../../../types/app';
import { formatCurrency } from '../../../utils/formatters';
import { DepositOptionChip } from '../components/DepositOptionChip';
import { useCreateFixedDepositMutation, useGetDepositProductsQuery } from '../services/depositApi';
import { selectFixedDepositProduct } from '../store/depositSelectors';
import { calculateFixedDepositMaturity } from '../utils/depositCalculators';
import { validateFixedDepositInput } from '../utils/depositValidators';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'OpenFixedDeposit'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
type TenureUnit = 'months' | 'years';
const PAYOUT_OPTIONS: Array<{
id: DepositInterestPayoutType;
label: string;
}> = [
{ id: 'cumulative', label: 'Cumulative' },
{ id: 'non-cumulative', label: 'Non-cumulative' },
];
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d]/g, ''));
export default function OpenFixedDepositScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
useGetDepositProductsQuery();
const product = useAppSelector(selectFixedDepositProduct);
const [createFixedDeposit, createState] = useCreateFixedDepositMutation();
const [amount, setAmount] = useState('');
const [tenureValue, setTenureValue] = useState(
String(product?.defaultTenureMonths ?? 12),
);
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
const [payoutType, setPayoutType] =
useState<DepositInterestPayoutType>('cumulative');
const [errors, setErrors] = useState<{
amount?: string;
tenureMonths?: string;
interestPayoutType?: string;
}>({});
const tenureMonths = useMemo(() => {
const numericValue = toPositiveNumber(tenureValue);
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
}, [tenureUnit, tenureValue]);
const amountNumber = useMemo(() => toPositiveNumber(amount), [amount]);
const maturityAmount = useMemo(() => {
if (!product) {
return 0;
}
return calculateFixedDepositMaturity(
amountNumber,
product.rate,
tenureMonths,
payoutType,
);
}, [amountNumber, payoutType, product, tenureMonths]);
const handleSubmit = async () => {
if (!product) {
return;
}
const validation = validateFixedDepositInput(
{
amount: amountNumber,
tenureMonths,
interestPayoutType: payoutType,
},
product,
);
setErrors(validation);
if (Object.keys(validation).length) {
toast('Check deposit inputs', 'error', 'Please correct the highlighted fields.');
return;
}
try {
const deposit = await createFixedDeposit({
amount: amountNumber,
tenureMonths,
interestPayoutType: payoutType,
}).unwrap();
toast('Fixed deposit created', 'success', 'Your new FD is now active.');
navigation.replace('DepositDetails', {
depositId: deposit.id,
kind: deposit.kind,
});
} catch {
toast('Unable to open fixed deposit', 'error', 'Please try again shortly.');
}
};
if (!product) {
return (
<ScreenShell title="Open fixed deposit" subtitle="Loading fixed deposit product">
<Card>
<Text style={[styles.placeholderText, { color: theme.colors.textSecondary }]}>
Loading product details...
</Text>
</Card>
</ScreenShell>
);
}
return (
<ScreenShell
title="Open fixed deposit"
subtitle="Create a new FD using your existing deposit product settings"
scrollable={false}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<Card style={styles.heroCard}>
<View style={styles.heroHeader}>
<View>
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
{product.name}
</Text>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
{product.rate}% p.a.
</Text>
</View>
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.accent}12` }]}>
<Ionicons
name="shield-checkmark-outline"
size={16}
color={theme.colors.accentStrong}
/>
<Text style={[styles.heroPillText, { color: theme.colors.accentStrong }]}>
Min {formatCurrency(product.minimumAmount ?? 0)}
</Text>
</View>
</View>
<Text style={[styles.heroCaption, { color: theme.colors.textSecondary }]}>
Maximum amount {formatCurrency(product.maximumAmount ?? 0)} · Tenure between{' '}
{product.minimumTenureMonths} and {product.maximumTenureMonths} months
</Text>
</Card>
<Card style={styles.formCard}>
<Input
label="Deposit amount"
value={amount}
onChangeText={setAmount}
keyboardType="number-pad"
placeholder="100000"
error={errors.amount}
/>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>Tenure</Text>
<Input
value={tenureValue}
onChangeText={setTenureValue}
keyboardType="number-pad"
placeholder={String(product.defaultTenureMonths)}
error={errors.tenureMonths}
/>
<View style={styles.optionRow}>
{TENURE_UNITS.map((unit) => (
<DepositOptionChip
key={unit}
label={unit === 'months' ? 'Months' : 'Years'}
selected={tenureUnit === unit}
onPress={() => setTenureUnit(unit)}
/>
))}
</View>
</View>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
Interest payout
</Text>
<View style={styles.optionRow}>
{PAYOUT_OPTIONS.map((option) => (
<DepositOptionChip
key={option.id}
label={option.label}
selected={payoutType === option.id}
onPress={() => setPayoutType(option.id)}
/>
))}
</View>
{errors.interestPayoutType ? (
<Text style={[styles.errorText, { color: theme.colors.error }]}>
{errors.interestPayoutType}
</Text>
) : null}
</View>
</Card>
<Card style={styles.previewCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Maturity preview
</Text>
<View style={styles.previewGrid}>
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Principal
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(amountNumber || 0)}
</Text>
</View>
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Estimated maturity
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.round(maturityAmount))}
</Text>
</View>
<View
style={[
styles.previewTile,
styles.previewTileWide,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Estimated interest earned
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.max(Math.round(maturityAmount) - amountNumber, 0))}
</Text>
</View>
</View>
</Card>
<Button
title="Create fixed deposit"
onPress={handleSubmit}
loading={createState.isLoading}
leftAccessory={
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
}
/>
</ScrollView>
<View style={{ height: 50 }} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
scrollContent: {
gap: 18,
paddingBottom: 24,
},
heroCard: {
gap: 10,
},
heroHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
marginTop: 4,
fontSize: 24,
fontWeight: '900',
letterSpacing: -0.5,
},
heroPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 8,
alignSelf: 'flex-start',
},
heroPillText: {
fontSize: 11,
fontWeight: '800',
},
heroCaption: {
fontSize: 13,
lineHeight: 18,
},
formCard: {
gap: 18,
},
fieldGroup: {
gap: 10,
},
fieldLabel: {
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
optionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
errorText: {
fontSize: 12,
fontWeight: '500',
},
previewCard: {
gap: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
},
previewGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
previewTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
previewTileWide: {
width: '100%',
},
previewLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
previewValue: {
fontSize: 16,
fontWeight: '900',
lineHeight: 22,
},
placeholderText: {
fontSize: 14,
fontWeight: '500',
},
});
@@ -0,0 +1,339 @@
import React, { useMemo, useState } from 'react';
import {
ScrollView,
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 { useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCurrency } from '../../../utils/formatters';
import { DepositOptionChip } from '../components/DepositOptionChip';
import { useCreateRecurringDepositMutation, useGetDepositProductsQuery } from '../services/depositApi';
import { selectRecurringDepositProduct } from '../store/depositSelectors';
import { calculateRecurringDepositMaturity } from '../utils/depositCalculators';
import { validateRecurringDepositInput } from '../utils/depositValidators';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'OpenRecurringDeposit'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
type TenureUnit = 'months' | 'years';
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d]/g, ''));
export default function OpenRecurringDepositScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
useGetDepositProductsQuery();
const product = useAppSelector(selectRecurringDepositProduct);
const [createRecurringDeposit, createState] = useCreateRecurringDepositMutation();
const [monthlyInstallment, setMonthlyInstallment] = useState('');
const [tenureValue, setTenureValue] = useState(
String(product?.defaultTenureMonths ?? 24),
);
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
const [errors, setErrors] = useState<{
monthlyInstallment?: string;
tenureMonths?: string;
}>({});
const installmentNumber = useMemo(
() => toPositiveNumber(monthlyInstallment),
[monthlyInstallment],
);
const tenureMonths = useMemo(() => {
const numericValue = toPositiveNumber(tenureValue);
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
}, [tenureUnit, tenureValue]);
const totalInvestment = installmentNumber * tenureMonths;
const maturityAmount = useMemo(() => {
if (!product) {
return 0;
}
return calculateRecurringDepositMaturity(
installmentNumber,
product.rate,
tenureMonths,
);
}, [installmentNumber, product, tenureMonths]);
const handleSubmit = async () => {
if (!product) {
return;
}
const validation = validateRecurringDepositInput(
{
monthlyInstallment: installmentNumber,
tenureMonths,
},
product,
);
setErrors(validation);
if (Object.keys(validation).length) {
toast('Check recurring deposit inputs', 'error', 'Please correct the highlighted fields.');
return;
}
try {
const deposit = await createRecurringDeposit({
monthlyInstallment: installmentNumber,
tenureMonths,
}).unwrap();
toast('Recurring deposit created', 'success', 'Your new RD is now active.');
navigation.replace('DepositDetails', {
depositId: deposit.id,
kind: deposit.kind,
});
} catch {
toast('Unable to open recurring deposit', 'error', 'Please try again shortly.');
}
};
if (!product) {
return (
<ScreenShell title="Open recurring deposit" subtitle="Loading recurring deposit product">
<Card>
<Text style={[styles.placeholderText, { color: theme.colors.textSecondary }]}>
Loading product details...
</Text>
</Card>
</ScreenShell>
);
}
return (
<ScreenShell
title="Open recurring deposit"
subtitle="Set a monthly installment and grow savings automatically"
scrollable={false}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<Card style={styles.heroCard}>
<View style={styles.heroHeader}>
<View>
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
{product.name}
</Text>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
{product.rate}% p.a.
</Text>
</View>
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="repeat-outline" size={16} color={theme.colors.primary} />
<Text style={[styles.heroPillText, { color: theme.colors.primary }]}>
Min {formatCurrency(product.minimumInstallment ?? 0)}
</Text>
</View>
</View>
<Text style={[styles.heroCaption, { color: theme.colors.textSecondary }]}>
Maximum installment {formatCurrency(product.maximumInstallment ?? 0)} · Tenure
between {product.minimumTenureMonths} and {product.maximumTenureMonths} months
</Text>
</Card>
<Card style={styles.formCard}>
<Input
label="Monthly installment"
value={monthlyInstallment}
onChangeText={setMonthlyInstallment}
keyboardType="number-pad"
placeholder="5000"
error={errors.monthlyInstallment}
/>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>Tenure</Text>
<Input
value={tenureValue}
onChangeText={setTenureValue}
keyboardType="number-pad"
placeholder={String(product.defaultTenureMonths)}
error={errors.tenureMonths}
/>
<View style={styles.optionRow}>
{TENURE_UNITS.map((unit) => (
<DepositOptionChip
key={unit}
label={unit === 'months' ? 'Months' : 'Years'}
selected={tenureUnit === unit}
onPress={() => setTenureUnit(unit)}
/>
))}
</View>
</View>
</Card>
<Card style={styles.previewCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Growth preview
</Text>
<View style={styles.previewGrid}>
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Total investment
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(totalInvestment || 0)}
</Text>
</View>
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Estimated maturity
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.round(maturityAmount))}
</Text>
</View>
<View
style={[
styles.previewTile,
styles.previewTileWide,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Estimated gains
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.max(Math.round(maturityAmount) - totalInvestment, 0))}
</Text>
</View>
</View>
</Card>
<Button
title="Create recurring deposit"
onPress={handleSubmit}
loading={createState.isLoading}
leftAccessory={
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
}
/>
</ScrollView>
<View style={{ height: 50 }} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
scrollContent: {
gap: 18,
paddingBottom: 24,
},
heroCard: {
gap: 10,
},
heroHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
marginTop: 4,
fontSize: 24,
fontWeight: '900',
letterSpacing: -0.5,
},
heroPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 8,
alignSelf: 'flex-start',
},
heroPillText: {
fontSize: 11,
fontWeight: '800',
},
heroCaption: {
fontSize: 13,
lineHeight: 18,
},
formCard: {
gap: 18,
},
fieldGroup: {
gap: 10,
},
fieldLabel: {
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
optionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
previewCard: {
gap: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
},
previewGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
previewTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
previewTileWide: {
width: '100%',
},
previewLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
previewValue: {
fontSize: 16,
fontWeight: '900',
lineHeight: 22,
},
placeholderText: {
fontSize: 14,
fontWeight: '500',
},
});