This commit is contained in:
Arkaprabha Chakraborty
2026-02-18 13:16:51 +05:30
commit 53742d0134
102 changed files with 22090 additions and 0 deletions

View File

@@ -0,0 +1,429 @@
import React, {useCallback, useEffect} from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
StatusBar,
} from 'react-native';
import {useFocusEffect} from '@react-navigation/native';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {BarChart, PieChart} from 'react-native-gifted-charts';
import {SummaryCard, SectionHeader, TransactionItem, EmptyState} from '../components';
import {useSettingsStore, useNetWorthStore, useExpenseStore} from '../store';
import {formatCurrency, formatCompact, percentageChange} from '../utils';
import {COLORS} from '../constants';
import {useThemeColors, useIsDarkTheme} from '../hooks';
const DashboardScreen: React.FC = () => {
const {t} = useTranslation();
const baseCurrency = useSettingsStore(s => s.baseCurrency);
const colors = useThemeColors();
const isDark = useIsDarkTheme();
const insets = useSafeAreaInsets();
const {
totalAssets,
totalLiabilities,
netWorth,
loadNetWorth,
isLoading: nwLoading,
} = useNetWorthStore();
const {
transactions,
monthlyExpense,
monthlyIncome,
spendingByCategory,
monthlyTrend,
loadTransactions,
loadMonthlyStats,
loadSpendingAnalytics,
isLoading: txLoading,
} = useExpenseStore();
const loadAll = useCallback(async () => {
await Promise.all([
loadNetWorth(),
loadTransactions({limit: 5}),
loadMonthlyStats(),
loadSpendingAnalytics(),
]);
}, [loadNetWorth, loadTransactions, loadMonthlyStats, loadSpendingAnalytics]);
// Reload on screen focus
useFocusEffect(
useCallback(() => {
loadAll();
}, [loadAll]),
);
const isLoading = nwLoading || txLoading;
// ─── Chart: Spending by Category (Pie) ─────────────────────────────
const pieData = spendingByCategory.slice(0, 6).map((item, idx) => ({
value: item.total,
text: formatCompact(item.total, baseCurrency),
color: item.categoryColor || colors.chartColors[idx % colors.chartColors.length],
focused: idx === 0,
}));
// ─── Chart: Monthly Trend (Bar) ────────────────────────────────────
const barData = monthlyTrend.map((item, idx) => ({
value: item.total,
label: item.month.slice(5), // "01", "02", etc.
frontColor: colors.chartColors[idx % colors.chartColors.length],
}));
// ─── Net Worth Calculation Breakdown ───────────────────────────────
// Net Worth = Total Assets - Total Liabilities
// This is already computed in the netWorthStore from live SQLite data.
return (
<SafeAreaView style={[styles.screen, {backgroundColor: colors.background}]} edges={['top', 'left', 'right']}>
<StatusBar barStyle={isDark ? 'light-content' : 'dark-content'} backgroundColor={colors.background} />
{/* Header */}
<View style={[styles.header, {backgroundColor: colors.background}]}>
<View>
<Text style={[styles.greeting, {color: colors.textSecondary}]}>Hello,</Text>
<Text style={[styles.headerTitle, {color: colors.text}]}>{t('dashboard.title')}</Text>
</View>
<View style={[styles.currencyBadge, {backgroundColor: colors.primary + '15'}]}>
<Text style={[styles.currencyText, {color: colors.primary}]}>{baseCurrency}</Text>
</View>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, {paddingBottom: 60 + insets.bottom}]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={loadAll} />
}>
{/* ── Net Worth Hero Card ─────────────────────────────────── */}
<View style={[styles.heroCard, {backgroundColor: colors.surface}]}>
<Text style={[styles.heroLabel, {color: colors.textSecondary}]}>{t('dashboard.netWorth')}</Text>
<Text
style={[
styles.heroValue,
{color: netWorth >= 0 ? colors.success : colors.danger},
]}
numberOfLines={1}
adjustsFontSizeToFit>
{formatCurrency(netWorth, baseCurrency)}
</Text>
<View style={[styles.heroBreakdown, {borderTopColor: colors.borderLight}]}>
<View style={styles.heroBreakdownItem}>
<Icon name="trending-up" size={16} color={colors.asset} />
<Text style={[styles.heroBreakdownLabel, {color: colors.textTertiary}]}>
{t('dashboard.totalAssets')}
</Text>
<Text style={[styles.heroBreakdownValue, {color: colors.asset}]}>
{formatCompact(totalAssets, baseCurrency)}
</Text>
</View>
<View style={[styles.heroBreakdownDivider, {backgroundColor: colors.borderLight}]} />
<View style={styles.heroBreakdownItem}>
<Icon name="trending-down" size={16} color={colors.liability} />
<Text style={[styles.heroBreakdownLabel, {color: colors.textTertiary}]}>
{t('dashboard.totalLiabilities')}
</Text>
<Text
style={[styles.heroBreakdownValue, {color: colors.liability}]}>
{formatCompact(totalLiabilities, baseCurrency)}
</Text>
</View>
</View>
</View>
{/* ── Monthly Summary Cards ──────────────────────────────── */}
<View style={styles.cardRow}>
<SummaryCard
title={t('dashboard.monthlyIncome')}
value={formatCurrency(monthlyIncome, baseCurrency)}
valueColor={colors.income}
icon={<Icon name="arrow-down-circle" size={18} color={colors.income} />}
style={styles.halfCard}
/>
<SummaryCard
title={t('dashboard.monthlySpending')}
value={formatCurrency(monthlyExpense, baseCurrency)}
valueColor={colors.expense}
icon={<Icon name="arrow-up-circle" size={18} color={colors.expense} />}
style={styles.halfCard}
/>
</View>
{/* ── Spending by Category (Pie Chart) ───────────────────── */}
{pieData.length > 0 && (
<>
<SectionHeader title={t('dashboard.thisMonth')} />
<View style={[styles.chartCard, {backgroundColor: colors.surface}]}>
<PieChart
data={pieData}
donut
innerRadius={50}
radius={80}
innerCircleColor={colors.surface}
centerLabelComponent={() => (
<View style={styles.pieCenter}>
<Text style={[styles.pieCenterValue, {color: colors.text}]}>
{formatCompact(monthlyExpense, baseCurrency)}
</Text>
<Text style={[styles.pieCenterLabel, {color: colors.textTertiary}]}>Spent</Text>
</View>
)}
/>
<View style={styles.legendContainer}>
{spendingByCategory.slice(0, 5).map((item, idx) => (
<View key={idx} style={styles.legendItem}>
<View
style={[
styles.legendDot,
{backgroundColor: item.categoryColor},
]}
/>
<Text style={[styles.legendText, {color: colors.text}]} numberOfLines={1}>
{item.categoryName}
</Text>
<Text style={[styles.legendValue, {color: colors.textSecondary}]}>
{formatCompact(item.total, baseCurrency)}
</Text>
</View>
))}
</View>
</View>
</>
)}
{/* ── Spending Trends (Bar Chart) ────────────────────────── */}
{barData.length > 0 && (
<>
<SectionHeader title={t('dashboard.spendingTrends')} />
<View style={[styles.chartCard, {backgroundColor: colors.surface}]}>
<BarChart
data={barData}
barWidth={28}
spacing={18}
roundedTop
roundedBottom
noOfSections={4}
yAxisThickness={0}
xAxisThickness={0}
yAxisTextStyle={[styles.chartAxisText, {color: colors.textTertiary}]}
xAxisLabelTextStyle={[styles.chartAxisText, {color: colors.textTertiary}]}
hideRules
isAnimated
barBorderRadius={6}
/>
</View>
</>
)}
{/* ── Recent Transactions ─────────────────────────────────── */}
<SectionHeader title={t('dashboard.recentTransactions')} />
<View style={[styles.transactionsList, {backgroundColor: colors.surface}]}>
{transactions.length > 0 ? (
transactions.map(txn => (
<TransactionItem key={txn.id} transaction={txn} />
))
) : (
<EmptyState
icon="receipt"
title={t('expenses.noTransactions')}
subtitle={t('expenses.startTracking')}
/>
)}
</View>
<View style={styles.bottomSpacer} />
</ScrollView>
</SafeAreaView>
);
};
export default DashboardScreen;
// ─── Styles ────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 8,
backgroundColor: COLORS.background,
},
greeting: {
fontSize: 14,
color: COLORS.textSecondary,
},
headerTitle: {
fontSize: 28,
fontWeight: '800',
color: COLORS.text,
},
currencyBadge: {
backgroundColor: COLORS.primary + '15',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
currencyText: {
fontSize: 13,
fontWeight: '700',
color: COLORS.primary,
},
scrollView: {
flex: 1,
},
scrollContent: {},
// Hero card
heroCard: {
backgroundColor: COLORS.surface,
margin: 20,
marginTop: 12,
borderRadius: 20,
padding: 24,
shadowColor: '#000',
shadowOffset: {width: 0, height: 4},
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 5,
},
heroLabel: {
fontSize: 13,
fontWeight: '600',
color: COLORS.textSecondary,
textTransform: 'uppercase',
letterSpacing: 1,
},
heroValue: {
fontSize: 36,
fontWeight: '800',
marginTop: 4,
marginBottom: 16,
},
heroBreakdown: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: COLORS.borderLight,
paddingTop: 16,
},
heroBreakdownItem: {
flex: 1,
alignItems: 'center',
},
heroBreakdownDivider: {
width: 1,
height: 36,
backgroundColor: COLORS.borderLight,
},
heroBreakdownLabel: {
fontSize: 11,
color: COLORS.textTertiary,
marginTop: 4,
},
heroBreakdownValue: {
fontSize: 16,
fontWeight: '700',
marginTop: 2,
},
// Monthly cards
cardRow: {
flexDirection: 'row',
paddingHorizontal: 20,
gap: 12,
},
halfCard: {
flex: 1,
},
// Charts
chartCard: {
backgroundColor: COLORS.surface,
marginHorizontal: 20,
borderRadius: 16,
padding: 20,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.04,
shadowRadius: 8,
elevation: 2,
},
chartAxisText: {
fontSize: 10,
color: COLORS.textTertiary,
},
pieCenter: {
alignItems: 'center',
},
pieCenterValue: {
fontSize: 16,
fontWeight: '700',
color: COLORS.text,
},
pieCenterLabel: {
fontSize: 11,
color: COLORS.textTertiary,
},
legendContainer: {
width: '100%',
marginTop: 16,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 4,
},
legendDot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 8,
},
legendText: {
flex: 1,
fontSize: 13,
color: COLORS.text,
},
legendValue: {
fontSize: 13,
fontWeight: '600',
color: COLORS.textSecondary,
},
// Transactions
transactionsList: {
backgroundColor: COLORS.surface,
marginHorizontal: 20,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.04,
shadowRadius: 8,
elevation: 2,
},
bottomSpacer: {
height: 40,
},
});

View File

@@ -0,0 +1,552 @@
/**
* ExpensesScreen — MD3 refactored.
* Replaces the system Modal with CustomBottomSheet.
* Uses MD3 theme tokens (useTheme), Reanimated animations, and haptic feedback.
*/
import React, {useCallback, useRef, useState} from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
ScrollView,
Pressable,
Alert,
StatusBar,
} from 'react-native';
import {useFocusEffect} from '@react-navigation/native';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated';
import dayjs from 'dayjs';
import {
TransactionItem,
EmptyState,
CustomBottomSheet,
BottomSheetInput,
BottomSheetChipSelector,
triggerHaptic,
} from '../components';
import type {CustomBottomSheetHandle} from '../components';
import {useExpenseStore, useSettingsStore, useNetWorthStore} from '../store';
import {PAYMENT_METHODS} from '../constants';
import {formatCurrency} from '../utils';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
import {
TransactionType,
PaymentMethod,
Category,
Transaction,
NetWorthTargetType,
AssetType,
LiabilityType,
} from '../types';
const ASSET_TYPES: AssetType[] = [
'Bank', 'Stocks', 'Gold', 'EPF', 'Mutual Funds', 'Fixed Deposit', 'PPF', 'Real Estate', 'Other',
];
const LIABILITY_TYPES: LiabilityType[] = [
'Home Loan', 'Car Loan', 'Personal Loan', 'Education Loan', 'Credit Card', 'Other',
];
const ExpensesScreen: React.FC = () => {
const {t} = useTranslation();
const baseCurrency = useSettingsStore(s => s.baseCurrency);
const theme = useTheme();
const s = makeStyles(theme);
const {colors, spacing} = theme;
const insets = useSafeAreaInsets();
const sheetRef = useRef<CustomBottomSheetHandle>(null);
const {
transactions,
categories,
monthlyExpense,
monthlyIncome,
loadTransactions,
loadMonthlyStats,
addTransaction,
removeTransaction,
} = useExpenseStore();
const {assets, liabilities, loadNetWorth} = useNetWorthStore();
const [txnType, setTxnType] = useState<TransactionType>('expense');
const [amount, setAmount] = useState('');
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>('UPI');
const [note, setNote] = useState('');
const [targetType, setTargetType] = useState<NetWorthTargetType>('asset');
const [targetMode, setTargetMode] = useState<'existing' | 'new'>('existing');
const [selectedTargetId, setSelectedTargetId] = useState('');
const [newTargetName, setNewTargetName] = useState('');
const [newAssetType, setNewAssetType] = useState<AssetType>('Bank');
const [newLiabilityType, setNewLiabilityType] = useState<LiabilityType>('Home Loan');
useFocusEffect(
useCallback(() => {
loadTransactions({limit: 100});
loadMonthlyStats();
loadNetWorth();
}, [loadTransactions, loadMonthlyStats, loadNetWorth]),
);
const filteredCategories = categories.filter(c => c.type === txnType);
const selectedTargets = targetType === 'asset' ? assets : liabilities;
const resetForm = () => {
setAmount('');
setSelectedCategory(null);
setPaymentMethod('UPI');
setNote('');
setTargetType('asset');
setTargetMode('existing');
setSelectedTargetId('');
setNewTargetName('');
setNewAssetType('Bank');
setNewLiabilityType('Home Loan');
};
const handleSave = async () => {
const parsed = parseFloat(amount);
if (isNaN(parsed) || parsed <= 0) {
Alert.alert('Invalid Amount', 'Please enter a valid amount.');
return;
}
if (!selectedCategory) {
Alert.alert('Select Category', 'Please select a category.');
return;
}
if (targetMode === 'existing' && !selectedTargetId) {
Alert.alert('Select Entry', 'Please select an existing asset or liability entry.');
return;
}
if (targetMode === 'new' && !newTargetName.trim()) {
Alert.alert('Entry Name Required', 'Please enter a name for the new net worth entry.');
return;
}
const operation =
txnType === 'income'
? targetType === 'asset' ? 'add' : 'subtract'
: targetType === 'asset' ? 'subtract' : 'add';
await addTransaction({
amount: parsed,
currency: baseCurrency,
type: txnType,
categoryId: selectedCategory.id,
paymentMethod,
note,
date: dayjs().format('YYYY-MM-DD'),
impact: {
targetType,
targetId: targetMode === 'existing' ? selectedTargetId : undefined,
operation,
createNew:
targetMode === 'new'
? {
name: newTargetName.trim(),
type: targetType === 'asset' ? newAssetType : newLiabilityType,
note: '',
}
: undefined,
},
});
triggerHaptic('notificationSuccess');
resetForm();
sheetRef.current?.dismiss();
loadNetWorth();
};
const handleDelete = (txn: Transaction) => {
Alert.alert('Delete Transaction', 'Are you sure you want to delete this?', [
{text: 'Cancel', style: 'cancel'},
{
text: 'Delete',
style: 'destructive',
onPress: () => {
triggerHaptic('impactMedium');
removeTransaction(txn.id);
},
},
]);
};
// ── List Header ─────────────────────────────────────────────────────
const renderHeader = () => (
<Animated.View entering={FadeInDown.duration(400)} style={s.headerSection}>
<View style={s.summaryRow}>
<View style={[s.summaryItem, {backgroundColor: colors.success + '12'}]}>
<Icon name="arrow-down-circle" size={20} color={colors.success} />
<Text style={s.summaryLabel}>Income</Text>
<Text style={[s.summaryValue, {color: colors.success}]}>
{formatCurrency(monthlyIncome, baseCurrency)}
</Text>
</View>
<View style={[s.summaryItem, {backgroundColor: colors.error + '12'}]}>
<Icon name="arrow-up-circle" size={20} color={colors.error} />
<Text style={s.summaryLabel}>Expense</Text>
<Text style={[s.summaryValue, {color: colors.error}]}>
{formatCurrency(monthlyExpense, baseCurrency)}
</Text>
</View>
</View>
</Animated.View>
);
// ── Category chip options ───────────────────────────────────────────
const categoryOptions = filteredCategories.map(c => ({
value: c.id,
label: c.name,
icon: c.icon,
}));
const paymentOptions = PAYMENT_METHODS.map(m => ({value: m, label: m}));
// ── Render ──────────────────────────────────────────────────────────
return (
<SafeAreaView style={s.screen} edges={['top', 'left', 'right']}>
<StatusBar
barStyle={theme.isDark ? 'light-content' : 'dark-content'}
backgroundColor={colors.background}
/>
<Animated.View entering={FadeIn.duration(300)} style={s.header}>
<Text style={s.headerTitle}>{t('expenses.title')}</Text>
</Animated.View>
<FlatList
data={transactions}
keyExtractor={item => item.id}
renderItem={({item}) => (
<TransactionItem transaction={item} onPress={handleDelete} />
)}
ListHeaderComponent={renderHeader}
ListEmptyComponent={
<EmptyState
icon="receipt"
title={t('expenses.noTransactions')}
subtitle={t('expenses.startTracking')}
/>
}
ItemSeparatorComponent={() => (
<View style={[s.separator, {backgroundColor: colors.outlineVariant + '30'}]} />
)}
contentContainerStyle={{paddingBottom: 80 + insets.bottom}}
showsVerticalScrollIndicator={false}
/>
{/* FAB */}
<Pressable
style={[s.fab, {bottom: 70 + insets.bottom}]}
onPress={() => {
resetForm();
triggerHaptic('impactMedium');
sheetRef.current?.present();
}}>
<Icon name="plus" size={28} color={colors.onPrimary} />
</Pressable>
{/* ── Add Transaction Bottom Sheet ──────────────────────── */}
<CustomBottomSheet
ref={sheetRef}
title={txnType === 'expense' ? t('expenses.addExpense') : t('expenses.addIncome')}
snapPoints={['92%']}
headerLeft={{label: t('common.cancel'), onPress: () => sheetRef.current?.dismiss()}}
headerRight={{label: t('common.save'), onPress: handleSave}}>
{/* Expense / Income Toggle */}
<View style={s.typeToggle}>
<Pressable
style={[s.typeBtn, txnType === 'expense' && {backgroundColor: colors.errorContainer}]}
onPress={() => {
triggerHaptic('selection');
setTxnType('expense');
setSelectedCategory(null);
}}>
<Text style={[s.typeBtnText, txnType === 'expense' && {color: colors.onErrorContainer}]}>
Expense
</Text>
</Pressable>
<Pressable
style={[s.typeBtn, txnType === 'income' && {backgroundColor: colors.primaryContainer}]}
onPress={() => {
triggerHaptic('selection');
setTxnType('income');
setSelectedCategory(null);
}}>
<Text style={[s.typeBtnText, txnType === 'income' && {color: colors.onPrimaryContainer}]}>
Income
</Text>
</Pressable>
</View>
{/* Amount */}
<BottomSheetInput
label={t('expenses.amount')}
value={amount}
onChangeText={setAmount}
placeholder="0"
keyboardType="decimal-pad"
prefix={baseCurrency === 'INR' ? '\u20B9' : baseCurrency === 'USD' ? '$' : '\u20AC'}
autoFocus
/>
{/* Category */}
<BottomSheetChipSelector
label={t('expenses.category')}
options={categoryOptions}
selected={selectedCategory?.id ?? ''}
onSelect={id => {
const cat = filteredCategories.find(c => c.id === id) ?? null;
setSelectedCategory(cat);
}}
/>
{/* Payment Method */}
<BottomSheetChipSelector
label={t('expenses.paymentMethod')}
options={paymentOptions}
selected={paymentMethod}
onSelect={m => setPaymentMethod(m as PaymentMethod)}
/>
{/* Net Worth Impact */}
<Text style={s.sectionLabel}>Net Worth Entry</Text>
<View style={s.typeToggle}>
<Pressable
style={[s.typeBtn, targetType === 'asset' && {backgroundColor: colors.primaryContainer}]}
onPress={() => {
triggerHaptic('selection');
setTargetType('asset');
setSelectedTargetId('');
}}>
<Text style={[s.typeBtnText, targetType === 'asset' && {color: colors.onPrimaryContainer}]}>
Asset
</Text>
</Pressable>
<Pressable
style={[s.typeBtn, targetType === 'liability' && {backgroundColor: colors.errorContainer}]}
onPress={() => {
triggerHaptic('selection');
setTargetType('liability');
setSelectedTargetId('');
}}>
<Text style={[s.typeBtnText, targetType === 'liability' && {color: colors.onErrorContainer}]}>
Liability
</Text>
</Pressable>
</View>
<View style={[s.typeToggle, {marginBottom: spacing.lg}]}>
<Pressable
style={[s.typeBtn, targetMode === 'existing' && {backgroundColor: colors.primaryContainer}]}
onPress={() => {
triggerHaptic('selection');
setTargetMode('existing');
}}>
<Text style={[s.typeBtnText, targetMode === 'existing' && {color: colors.onPrimaryContainer}]}>
Existing
</Text>
</Pressable>
<Pressable
style={[s.typeBtn, targetMode === 'new' && {backgroundColor: colors.primaryContainer}]}
onPress={() => {
triggerHaptic('selection');
setTargetMode('new');
setSelectedTargetId('');
}}>
<Text style={[s.typeBtnText, targetMode === 'new' && {color: colors.onPrimaryContainer}]}>
New
</Text>
</Pressable>
</View>
{targetMode === 'existing' ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{marginBottom: spacing.lg}}>
{selectedTargets.map(entry => {
const active = selectedTargetId === entry.id;
return (
<Pressable
key={entry.id}
style={[
s.chip,
active && {backgroundColor: colors.primaryContainer, borderColor: colors.primary},
]}
onPress={() => {
triggerHaptic('selection');
setSelectedTargetId(entry.id);
}}>
<Text style={[s.chipText, active && {color: colors.onPrimaryContainer}]}>
{entry.name}
</Text>
</Pressable>
);
})}
</ScrollView>
) : (
<View style={{marginBottom: spacing.lg}}>
<BottomSheetInput
label={targetType === 'asset' ? 'Asset Name' : 'Liability Name'}
value={newTargetName}
onChangeText={setNewTargetName}
placeholder={targetType === 'asset' ? 'New asset name' : 'New liability name'}
/>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{(targetType === 'asset' ? ASSET_TYPES : LIABILITY_TYPES).map(entryType => {
const active = (targetType === 'asset' ? newAssetType : newLiabilityType) === entryType;
return (
<Pressable
key={entryType}
style={[
s.chip,
active && {backgroundColor: colors.primaryContainer, borderColor: colors.primary},
]}
onPress={() => {
triggerHaptic('selection');
if (targetType === 'asset') setNewAssetType(entryType as AssetType);
else setNewLiabilityType(entryType as LiabilityType);
}}>
<Text style={[s.chipText, active && {color: colors.onPrimaryContainer}]}>
{entryType}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
)}
<Text style={s.operationHint}>
{txnType === 'income'
? targetType === 'asset'
? 'Income will add to this asset.'
: 'Income will reduce this liability.'
: targetType === 'asset'
? 'Expense will reduce this asset.'
: 'Expense will increase this liability.'}
</Text>
{/* Note */}
<BottomSheetInput
label={t('expenses.note')}
value={note}
onChangeText={setNote}
placeholder="Add a note..."
multiline
/>
</CustomBottomSheet>
</SafeAreaView>
);
};
export default ExpensesScreen;
// ─── Styles ────────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
screen: {flex: 1, backgroundColor: colors.background},
header: {
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.sm,
},
headerTitle: {
...typography.headlineMedium,
color: colors.onSurface,
fontWeight: '700',
},
headerSection: {
paddingHorizontal: spacing.xl,
paddingBottom: spacing.md,
},
summaryRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.sm,
marginBottom: spacing.lg,
},
summaryItem: {
flex: 1,
borderRadius: shape.large,
padding: spacing.lg,
alignItems: 'center',
},
summaryLabel: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginTop: spacing.xs,
},
summaryValue: {
...typography.titleMedium,
fontWeight: '700',
marginTop: 2,
},
separator: {height: 1, marginLeft: 72},
fab: {
position: 'absolute',
right: spacing.xl,
width: 56,
height: 56,
borderRadius: shape.large,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
...elevation.level3,
},
typeToggle: {
flexDirection: 'row',
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.medium,
padding: 4,
marginBottom: spacing.xl,
},
typeBtn: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: shape.small,
alignItems: 'center',
},
typeBtnText: {
...typography.labelLarge,
color: colors.onSurfaceVariant,
},
chip: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: shape.full,
borderWidth: 1,
borderColor: colors.outlineVariant,
backgroundColor: colors.surfaceContainerLowest,
marginRight: spacing.sm,
},
chipText: {
...typography.labelMedium,
color: colors.onSurfaceVariant,
},
sectionLabel: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginBottom: spacing.sm,
},
operationHint: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginBottom: spacing.lg,
fontStyle: 'italic',
},
});
}

View File

@@ -0,0 +1,213 @@
/**
* ModernDashboard — Full MD3 redesign of the dashboard screen.
*
* Sections:
* 1. Net Worth "Hero" Card with sparkline trend
* 2. Asset vs. Liability chip row
* 3. Wealth Distribution donut chart
* 4. Financial Health gauges (budget vs. spent)
* 5. Recent Activity glassmorphism list
*/
import React, {useCallback, useState} from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
StatusBar,
Pressable,
} from 'react-native';
import {useFocusEffect, useNavigation} from '@react-navigation/native';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeIn} from 'react-native-reanimated';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
import {useSettingsStore, useNetWorthStore, useExpenseStore} from '../store';
import {getNetWorthHistory} from '../db';
import type {NetWorthSnapshot} from '../types';
import {
NetWorthHeroCard,
AssetChipRow,
WealthDistributionChart,
RecentActivityList,
FinancialHealthGauges,
} from '../components/dashboard';
const ModernDashboard: React.FC = () => {
const {t} = useTranslation();
const theme = useTheme();
const s = makeStyles(theme);
const insets = useSafeAreaInsets();
const navigation = useNavigation<any>();
const baseCurrency = useSettingsStore(ss => ss.baseCurrency);
const {
totalAssets,
totalLiabilities,
netWorth,
assets,
liabilities,
loadNetWorth,
isLoading: nwLoading,
} = useNetWorthStore();
const {
transactions,
monthlyExpense,
monthlyIncome,
loadTransactions,
loadMonthlyStats,
loadSpendingAnalytics,
isLoading: txLoading,
} = useExpenseStore();
const [history, setHistory] = useState<NetWorthSnapshot[]>([]);
const loadAll = useCallback(async () => {
await Promise.all([
loadNetWorth(),
loadTransactions({limit: 5}),
loadMonthlyStats(),
loadSpendingAnalytics(),
]);
const hist = await getNetWorthHistory(12);
setHistory(hist);
}, [loadNetWorth, loadTransactions, loadMonthlyStats, loadSpendingAnalytics]);
useFocusEffect(
useCallback(() => {
loadAll();
}, [loadAll]),
);
const isLoading = nwLoading || txLoading;
return (
<SafeAreaView style={s.screen} edges={['top', 'left', 'right']}>
<StatusBar
barStyle={theme.isDark ? 'light-content' : 'dark-content'}
backgroundColor={theme.colors.background}
/>
{/* ── Header ──────────────────────────────────────────────── */}
<Animated.View entering={FadeIn.duration(400)} style={s.header}>
<View>
<Text style={s.greeting}>Hello,</Text>
<Text style={s.headerTitle}>{t('dashboard.title')}</Text>
</View>
<Pressable style={s.currencyBadge}>
<Icon name="swap-horizontal" size={14} color={theme.colors.primary} />
<Text style={s.currencyText}>{baseCurrency}</Text>
</Pressable>
</Animated.View>
{/* ── Scrollable Content ──────────────────────────────────── */}
<ScrollView
style={s.scrollView}
contentContainerStyle={{paddingBottom: 80 + insets.bottom}}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={loadAll}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}>
{/* 1. Net Worth Hero Card */}
<NetWorthHeroCard
netWorth={netWorth}
totalAssets={totalAssets}
totalLiabilities={totalLiabilities}
currency={baseCurrency}
history={history}
/>
{/* 2. Asset vs. Liability Chip Row */}
<AssetChipRow
assets={assets}
liabilities={liabilities}
currency={baseCurrency}
/>
{/* 3. Wealth Distribution Donut */}
<WealthDistributionChart assets={assets} currency={baseCurrency} />
{/* 4. Financial Health Gauges */}
<FinancialHealthGauges
monthlyIncome={monthlyIncome}
monthlyExpense={monthlyExpense}
currency={baseCurrency}
/>
{/* 5. Recent Activity */}
<RecentActivityList
transactions={transactions}
currency={baseCurrency}
onViewAll={() => navigation.navigate('Expenses')}
/>
<View style={s.bottomSpacer} />
</ScrollView>
</SafeAreaView>
);
};
export default ModernDashboard;
// ─── Styles ────────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, spacing} = theme;
return StyleSheet.create({
screen: {
flex: 1,
backgroundColor: colors.background,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.sm,
},
greeting: {
...typography.bodyMedium,
color: colors.onSurfaceVariant,
},
headerTitle: {
...typography.headlineMedium,
color: colors.onSurface,
fontWeight: '700',
},
currencyBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.primaryContainer,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: 20,
},
currencyText: {
...typography.labelMedium,
color: colors.onPrimaryContainer,
fontWeight: '600',
},
scrollView: {
flex: 1,
},
bottomSpacer: {
height: 20,
},
});
}

View File

@@ -0,0 +1,633 @@
/**
* NetWorthScreen — MD3 refactored.
* Replaces system Modals with CustomBottomSheet.
* Uses MD3 theme tokens (useTheme), Reanimated animations, and haptic feedback.
*/
import React, {useCallback, useRef, useState} from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Pressable,
Alert,
RefreshControl,
StatusBar,
} from 'react-native';
import {useFocusEffect} from '@react-navigation/native';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {LineChart} from 'react-native-gifted-charts';
import Animated, {FadeIn, FadeInDown, FadeInUp} from 'react-native-reanimated';
import {
SectionHeader,
EmptyState,
CustomBottomSheet,
BottomSheetInput,
BottomSheetChipSelector,
triggerHaptic,
} from '../components';
import type {CustomBottomSheetHandle} from '../components';
import {useNetWorthStore, useSettingsStore} from '../store';
import {getNetWorthHistory} from '../db';
import {formatCurrency, formatCompact} from '../utils';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
import {Asset, AssetType, Liability, LiabilityType, NetWorthSnapshot} from '../types';
const ASSET_TYPES: AssetType[] = [
'Bank', 'Stocks', 'Gold', 'EPF', 'Mutual Funds', 'Fixed Deposit', 'PPF', 'Real Estate', 'Other',
];
const LIABILITY_TYPES: LiabilityType[] = [
'Home Loan', 'Car Loan', 'Personal Loan', 'Education Loan', 'Credit Card', 'Other',
];
const ASSET_ICONS: Record<AssetType, string> = {
Bank: 'bank',
Stocks: 'chart-line',
Gold: 'gold',
EPF: 'shield-account',
'Real Estate': 'home-city',
'Mutual Funds': 'chart-areaspline',
'Fixed Deposit': 'safe',
PPF: 'piggy-bank',
Other: 'dots-horizontal',
};
const ASSET_ICON_COLORS: Record<AssetType, string> = {
Bank: '#1E88E5',
Stocks: '#7E57C2',
Gold: '#D4AF37',
EPF: '#00ACC1',
'Real Estate': '#8D6E63',
'Mutual Funds': '#26A69A',
'Fixed Deposit': '#3949AB',
PPF: '#43A047',
Other: '#78909C',
};
const LIABILITY_ICON_COLORS: Record<LiabilityType, {icon: string; color: string}> = {
'Home Loan': {icon: 'home-city', color: '#EF6C00'},
'Car Loan': {icon: 'car', color: '#5E35B1'},
'Personal Loan': {icon: 'account-cash', color: '#E53935'},
'Education Loan': {icon: 'school', color: '#1E88E5'},
'Credit Card': {icon: 'credit-card', color: '#D81B60'},
Other: {icon: 'alert-circle-outline', color: '#757575'},
};
const NetWorthScreen: React.FC = () => {
const {t} = useTranslation();
const baseCurrency = useSettingsStore(s => s.baseCurrency);
const theme = useTheme();
const s = makeStyles(theme);
const {colors, spacing, shape} = theme;
const insets = useSafeAreaInsets();
const assetSheetRef = useRef<CustomBottomSheetHandle>(null);
const liabilitySheetRef = useRef<CustomBottomSheetHandle>(null);
const {
assets,
liabilities,
totalAssets,
totalLiabilities,
netWorth,
isLoading,
loadNetWorth,
addAsset,
removeAsset,
editAsset,
addLiability,
removeLiability,
editLiability,
takeSnapshot,
} = useNetWorthStore();
const [history, setHistory] = useState<NetWorthSnapshot[]>([]);
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
const [editingLiability, setEditingLiability] = useState<Liability | null>(null);
// Asset form
const [assetName, setAssetName] = useState('');
const [assetType, setAssetType] = useState<AssetType>('Bank');
const [assetValue, setAssetValue] = useState('');
const [assetNote, setAssetNote] = useState('');
// Liability form
const [liabName, setLiabName] = useState('');
const [liabType, setLiabType] = useState<LiabilityType>('Home Loan');
const [liabAmount, setLiabAmount] = useState('');
const [liabRate, setLiabRate] = useState('');
const [liabEmi, setLiabEmi] = useState('');
const [liabNote, setLiabNote] = useState('');
const loadAll = useCallback(async () => {
await loadNetWorth();
const hist = await getNetWorthHistory(12);
setHistory(hist);
}, [loadNetWorth]);
useFocusEffect(useCallback(() => { loadAll(); }, [loadAll]));
// Chart data
const lineData = history.map(snap => ({
value: snap.netWorth,
label: snap.snapshotDate.slice(5, 10),
dataPointText: formatCompact(snap.netWorth, baseCurrency),
}));
// Asset type chip options
const assetTypeOptions = ASSET_TYPES.map(at => ({
value: at,
label: at,
icon: ASSET_ICONS[at],
}));
const liabTypeOptions = LIABILITY_TYPES.map(lt => ({
value: lt,
label: lt,
}));
// ── Save Asset ──────────────────────────────────────────────────
const handleSaveAsset = async () => {
const val = parseFloat(assetValue);
if (!assetName.trim() || isNaN(val) || val <= 0) {
Alert.alert('Invalid', 'Please enter a valid name and value.');
return;
}
if (editingAsset) {
await editAsset(editingAsset.id, {
name: assetName.trim(),
type: assetType,
currentValue: val,
note: assetNote,
});
} else {
await addAsset({
name: assetName.trim(),
type: assetType,
currentValue: val,
currency: baseCurrency,
note: assetNote,
});
}
triggerHaptic('notificationSuccess');
await takeSnapshot(baseCurrency);
setAssetName(''); setAssetValue(''); setAssetNote(''); setEditingAsset(null);
assetSheetRef.current?.dismiss();
loadAll();
};
// ── Save Liability ──────────────────────────────────────────────
const handleSaveLiability = async () => {
const amt = parseFloat(liabAmount);
if (!liabName.trim() || isNaN(amt) || amt <= 0) {
Alert.alert('Invalid', 'Please enter a valid name and amount.');
return;
}
if (editingLiability) {
await editLiability(editingLiability.id, {
name: liabName.trim(),
type: liabType,
outstandingAmount: amt,
interestRate: parseFloat(liabRate) || 0,
emiAmount: parseFloat(liabEmi) || 0,
note: liabNote,
});
} else {
await addLiability({
name: liabName.trim(),
type: liabType,
outstandingAmount: amt,
currency: baseCurrency,
interestRate: parseFloat(liabRate) || 0,
emiAmount: parseFloat(liabEmi) || 0,
note: liabNote,
});
}
triggerHaptic('notificationSuccess');
await takeSnapshot(baseCurrency);
setLiabName(''); setLiabAmount(''); setLiabRate('');
setLiabEmi(''); setLiabNote(''); setEditingLiability(null);
liabilitySheetRef.current?.dismiss();
loadAll();
};
// ── Delete / Edit handlers ──────────────────────────────────────
const handleDeleteAsset = (asset: Asset) => {
Alert.alert('Delete Asset', `Remove "${asset.name}"?`, [
{text: 'Cancel', style: 'cancel'},
{text: 'Delete', style: 'destructive', onPress: () => {
triggerHaptic('impactMedium');
removeAsset(asset.id);
}},
]);
};
const handleDeleteLiability = (liab: Liability) => {
Alert.alert('Delete Liability', `Remove "${liab.name}"?`, [
{text: 'Cancel', style: 'cancel'},
{text: 'Delete', style: 'destructive', onPress: () => {
triggerHaptic('impactMedium');
removeLiability(liab.id);
}},
]);
};
const handleEditAsset = (asset: Asset) => {
setEditingAsset(asset);
setAssetName(asset.name);
setAssetType(asset.type as AssetType);
setAssetValue(asset.currentValue.toString());
setAssetNote(asset.note || '');
assetSheetRef.current?.present();
};
const handleEditLiability = (liab: Liability) => {
setEditingLiability(liab);
setLiabName(liab.name);
setLiabType(liab.type as LiabilityType);
setLiabAmount(liab.outstandingAmount.toString());
setLiabRate(liab.interestRate.toString());
setLiabEmi(liab.emiAmount.toString());
setLiabNote(liab.note || '');
liabilitySheetRef.current?.present();
};
const handleOpenAddAsset = () => {
setEditingAsset(null);
setAssetName(''); setAssetType('Bank'); setAssetValue(''); setAssetNote('');
assetSheetRef.current?.present();
};
const handleOpenAddLiability = () => {
setEditingLiability(null);
setLiabName(''); setLiabType('Home Loan'); setLiabAmount('');
setLiabRate(''); setLiabEmi(''); setLiabNote('');
liabilitySheetRef.current?.present();
};
// ── Render ──────────────────────────────────────────────────────
return (
<SafeAreaView style={s.screen} edges={['top', 'left', 'right']}>
<StatusBar
barStyle={theme.isDark ? 'light-content' : 'dark-content'}
backgroundColor={colors.background}
/>
<Animated.View entering={FadeIn.duration(300)} style={s.header}>
<Text style={s.headerTitle}>{t('netWorth.title')}</Text>
</Animated.View>
<ScrollView
style={{flex: 1}}
contentContainerStyle={{paddingBottom: 60 + insets.bottom}}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isLoading} onRefresh={loadAll} />}>
{/* Hero Card */}
<Animated.View entering={FadeInDown.springify().damping(18)} style={s.heroCard}>
<Text style={s.heroLabel}>{t('dashboard.netWorth')}</Text>
<Text
style={[s.heroValue, {color: netWorth >= 0 ? colors.success : colors.error}]}
numberOfLines={1}
adjustsFontSizeToFit>
{formatCurrency(netWorth, baseCurrency)}
</Text>
<View style={s.heroSplit}>
<View style={s.heroSplitItem}>
<Icon name="trending-up" size={16} color={colors.success} />
<Text style={[s.heroSplitLabel, {color: colors.onSurfaceVariant}]}>Assets</Text>
<Text style={[s.heroSplitValue, {color: colors.success}]}>
{formatCurrency(totalAssets, baseCurrency)}
</Text>
</View>
<View style={[s.heroSplitDivider, {backgroundColor: colors.outlineVariant}]} />
<View style={s.heroSplitItem}>
<Icon name="trending-down" size={16} color={colors.error} />
<Text style={[s.heroSplitLabel, {color: colors.onSurfaceVariant}]}>Liabilities</Text>
<Text style={[s.heroSplitValue, {color: colors.error}]}>
{formatCurrency(totalLiabilities, baseCurrency)}
</Text>
</View>
</View>
</Animated.View>
{/* Chart */}
{lineData.length > 1 && (
<Animated.View entering={FadeInUp.duration(400).delay(100)}>
<SectionHeader title={t('netWorth.growth')} />
<View style={s.chartCard}>
<LineChart
data={lineData}
curved
color={colors.primary}
thickness={2}
dataPointsColor={colors.primary}
startFillColor={colors.primary}
endFillColor={colors.primary + '05'}
areaChart
yAxisTextStyle={{fontSize: 11, color: colors.onSurfaceVariant}}
xAxisLabelTextStyle={{fontSize: 11, color: colors.onSurfaceVariant}}
hideRules
yAxisThickness={0}
xAxisThickness={0}
noOfSections={4}
isAnimated
/>
</View>
</Animated.View>
)}
{/* Assets List */}
<Animated.View entering={FadeInUp.duration(400).delay(200)}>
<SectionHeader
title={t('netWorth.assets')}
actionLabel={t('common.add')}
onAction={handleOpenAddAsset}
/>
<View style={s.listCard}>
{assets.length > 0 ? (
assets.map((asset, idx) => {
const iconColor = ASSET_ICON_COLORS[asset.type as AssetType] || colors.primary;
const iconName = ASSET_ICONS[asset.type as AssetType] || 'dots-horizontal';
return (
<Pressable
key={asset.id}
style={[s.listItem, idx < assets.length - 1 && s.listItemBorder]}
onPress={() => handleEditAsset(asset)}
onLongPress={() => handleDeleteAsset(asset)}
android_ripple={{color: colors.primary + '12'}}>
<View style={[s.listIcon, {backgroundColor: iconColor + '1A'}]}>
<Icon name={iconName} size={20} color={iconColor} />
</View>
<View style={s.listDetails}>
<Text style={s.listName}>{asset.name}</Text>
<Text style={s.listType}>{asset.type}</Text>
</View>
<Text style={[s.listValue, {color: colors.success}]}>
{formatCurrency(asset.currentValue, baseCurrency)}
</Text>
</Pressable>
);
})
) : (
<EmptyState icon="wallet-plus" title={t('netWorth.noAssets')} />
)}
</View>
</Animated.View>
{/* Liabilities List */}
<Animated.View entering={FadeInUp.duration(400).delay(300)}>
<SectionHeader
title={t('netWorth.liabilities')}
actionLabel={t('common.add')}
onAction={handleOpenAddLiability}
/>
<View style={s.listCard}>
{liabilities.length > 0 ? (
liabilities.map((liab, idx) => {
const liabVisual = LIABILITY_ICON_COLORS[liab.type as LiabilityType] || LIABILITY_ICON_COLORS.Other;
return (
<Pressable
key={liab.id}
style={[s.listItem, idx < liabilities.length - 1 && s.listItemBorder]}
onPress={() => handleEditLiability(liab)}
onLongPress={() => handleDeleteLiability(liab)}
android_ripple={{color: colors.primary + '12'}}>
<View style={[s.listIcon, {backgroundColor: liabVisual.color + '1A'}]}>
<Icon name={liabVisual.icon} size={20} color={liabVisual.color} />
</View>
<View style={s.listDetails}>
<Text style={s.listName}>{liab.name}</Text>
<Text style={s.listType}>
{liab.type} · {liab.interestRate}% · EMI {formatCompact(liab.emiAmount, baseCurrency)}
</Text>
</View>
<Text style={[s.listValue, {color: colors.error}]}>
{formatCurrency(liab.outstandingAmount, baseCurrency)}
</Text>
</Pressable>
);
})
) : (
<EmptyState icon="credit-card-off" title={t('netWorth.noLiabilities')} />
)}
</View>
</Animated.View>
</ScrollView>
{/* ── Asset Bottom Sheet ─────────────────────────────────────── */}
<CustomBottomSheet
ref={assetSheetRef}
title={editingAsset ? 'Edit Asset' : t('netWorth.addAsset')}
snapPoints={['80%']}
headerLeft={{label: t('common.cancel'), onPress: () => assetSheetRef.current?.dismiss()}}
headerRight={{label: t('common.save'), onPress: handleSaveAsset}}>
<BottomSheetInput
label={t('netWorth.assetName')}
value={assetName}
onChangeText={setAssetName}
placeholder="e.g. HDFC Savings"
/>
<BottomSheetChipSelector
label={t('netWorth.assetType')}
options={assetTypeOptions}
selected={assetType}
onSelect={at => setAssetType(at as AssetType)}
/>
<BottomSheetInput
label={t('netWorth.currentValue')}
value={assetValue}
onChangeText={setAssetValue}
placeholder="0"
keyboardType="decimal-pad"
prefix={baseCurrency === 'INR' ? '\u20B9' : baseCurrency === 'USD' ? '$' : '\u20AC'}
/>
<BottomSheetInput
label="Note"
value={assetNote}
onChangeText={setAssetNote}
placeholder="Optional note"
/>
</CustomBottomSheet>
{/* ── Liability Bottom Sheet ─────────────────────────────────── */}
<CustomBottomSheet
ref={liabilitySheetRef}
title={editingLiability ? 'Edit Liability' : t('netWorth.addLiability')}
snapPoints={['90%']}
headerLeft={{label: t('common.cancel'), onPress: () => liabilitySheetRef.current?.dismiss()}}
headerRight={{label: t('common.save'), onPress: handleSaveLiability}}>
<BottomSheetInput
label={t('netWorth.liabilityName')}
value={liabName}
onChangeText={setLiabName}
placeholder="e.g. SBI Home Loan"
/>
<BottomSheetChipSelector
label={t('netWorth.liabilityType')}
options={liabTypeOptions}
selected={liabType}
onSelect={lt => setLiabType(lt as LiabilityType)}
/>
<BottomSheetInput
label={t('netWorth.outstandingAmount')}
value={liabAmount}
onChangeText={setLiabAmount}
placeholder="0"
keyboardType="decimal-pad"
prefix={baseCurrency === 'INR' ? '\u20B9' : baseCurrency === 'USD' ? '$' : '\u20AC'}
/>
<BottomSheetInput
label={t('netWorth.interestRate')}
value={liabRate}
onChangeText={setLiabRate}
placeholder="e.g. 8.5"
keyboardType="decimal-pad"
prefix="%"
/>
<BottomSheetInput
label={t('netWorth.emiAmount')}
value={liabEmi}
onChangeText={setLiabEmi}
placeholder="0"
keyboardType="decimal-pad"
prefix={baseCurrency === 'INR' ? '\u20B9' : baseCurrency === 'USD' ? '$' : '\u20AC'}
/>
<BottomSheetInput
label="Note"
value={liabNote}
onChangeText={setLiabNote}
placeholder="Optional note"
/>
</CustomBottomSheet>
</SafeAreaView>
);
};
export default NetWorthScreen;
// ─── Styles ────────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
screen: {flex: 1, backgroundColor: colors.background},
header: {
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.sm,
},
headerTitle: {
...typography.headlineMedium,
color: colors.onSurface,
fontWeight: '700',
},
heroCard: {
backgroundColor: colors.surfaceContainerLow,
marginHorizontal: spacing.xl,
marginTop: spacing.md,
borderRadius: shape.extraLarge,
padding: spacing.xl,
...elevation.level2,
},
heroLabel: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
textTransform: 'uppercase',
letterSpacing: 1,
},
heroValue: {
...typography.displaySmall,
fontWeight: '800',
marginTop: spacing.xs,
},
heroSplit: {
flexDirection: 'row',
marginTop: spacing.lg,
paddingTop: spacing.lg,
borderTopWidth: 1,
borderTopColor: colors.outlineVariant + '40',
},
heroSplitItem: {
flex: 1,
alignItems: 'center',
gap: 2,
},
heroSplitDivider: {
width: 1,
marginHorizontal: spacing.md,
},
heroSplitLabel: {
...typography.bodySmall,
},
heroSplitValue: {
...typography.titleSmall,
fontWeight: '700',
},
chartCard: {
backgroundColor: colors.surfaceContainerLow,
marginHorizontal: spacing.xl,
borderRadius: shape.large,
padding: spacing.xl,
alignItems: 'center',
...elevation.level1,
},
listCard: {
backgroundColor: colors.surfaceContainerLow,
marginHorizontal: spacing.xl,
borderRadius: shape.large,
overflow: 'hidden',
...elevation.level1,
},
listItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg,
},
listItemBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.outlineVariant + '30',
},
listIcon: {
width: 42,
height: 42,
borderRadius: 21,
justifyContent: 'center',
alignItems: 'center',
},
listDetails: {flex: 1, marginLeft: spacing.md},
listName: {
...typography.bodyLarge,
color: colors.onSurface,
fontWeight: '600',
},
listType: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginTop: 2,
},
listValue: {
...typography.titleSmall,
fontWeight: '700',
},
});
}

View File

@@ -0,0 +1,379 @@
/**
* SettingsScreen — Refactored with MD3 theme and CustomBottomSheet
* for selection dialogs (replaces system Alert-based selectors).
*/
import React, {useRef} from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Pressable,
Alert,
StatusBar,
} from 'react-native';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated';
import {
CustomBottomSheet,
triggerHaptic,
} from '../components';
import type {CustomBottomSheetHandle} from '../components';
import {useSettingsStore} from '../store';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
import {Currency} from '../types';
const CURRENCIES: {label: string; value: Currency; icon: string}[] = [
{label: 'Indian Rupee (\u20B9)', value: 'INR', icon: 'currency-inr'},
{label: 'US Dollar ($)', value: 'USD', icon: 'currency-usd'},
{label: 'Euro (\u20AC)', value: 'EUR', icon: 'currency-eur'},
{label: 'British Pound (\u00A3)', value: 'GBP', icon: 'currency-gbp'},
];
const THEMES: {label: string; value: 'light' | 'dark' | 'system'; icon: string}[] = [
{label: 'Light', value: 'light', icon: 'white-balance-sunny'},
{label: 'Dark', value: 'dark', icon: 'moon-waning-crescent'},
{label: 'System', value: 'system', icon: 'theme-light-dark'},
];
// ── Extracted SettingsRow for lint compliance ──────────────────────
interface SettingsRowProps {
icon: string;
iconColor?: string;
label: string;
value?: string;
onPress?: () => void;
destructive?: boolean;
theme: MD3Theme;
}
const SettingsRow: React.FC<SettingsRowProps> = ({
icon,
iconColor,
label,
value,
onPress,
destructive,
theme: thm,
}) => {
const {colors, typography, shape, spacing} = thm;
return (
<Pressable
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg,
}}
onPress={onPress}
disabled={!onPress}
android_ripple={{color: colors.primary + '12'}}>
<View
style={{
width: 36,
height: 36,
borderRadius: shape.small,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: (iconColor || colors.primary) + '14',
marginRight: spacing.md,
}}>
<Icon name={icon} size={20} color={iconColor || colors.primary} />
</View>
<Text
style={{
flex: 1,
...typography.bodyLarge,
color: destructive ? colors.error : colors.onSurface,
}}>
{label}
</Text>
{value ? (
<Text
style={{
...typography.bodyMedium,
color: colors.onSurfaceVariant,
marginRight: spacing.xs,
}}>
{value}
</Text>
) : null}
{onPress ? (
<Icon name="chevron-right" size={20} color={colors.onSurfaceVariant} />
) : null}
</Pressable>
);
};
// ── Main Component ────────────────────────────────────────────────
const SettingsScreen: React.FC = () => {
const {t} = useTranslation();
const {baseCurrency, setBaseCurrency, theme: themeSetting, setTheme} = useSettingsStore();
const theme = useTheme();
const s = makeStyles(theme);
const {colors} = theme;
const insets = useSafeAreaInsets();
const currencySheetRef = useRef<CustomBottomSheetHandle>(null);
const themeSheetRef = useRef<CustomBottomSheetHandle>(null);
const handleClearData = () => {
Alert.alert(
t('settings.clearData'),
t('settings.clearDataConfirm'),
[
{text: t('common.cancel'), style: 'cancel'},
{
text: t('common.confirm'),
style: 'destructive',
onPress: async () => {
triggerHaptic('impactMedium');
Alert.alert('Done', 'All data has been cleared.');
},
},
],
);
};
return (
<SafeAreaView style={s.screen} edges={['top', 'left', 'right']}>
<StatusBar
barStyle={theme.isDark ? 'light-content' : 'dark-content'}
backgroundColor={colors.background}
/>
<Animated.View entering={FadeIn.duration(300)} style={s.header}>
<Text style={s.headerTitle}>{t('settings.title')}</Text>
</Animated.View>
<ScrollView
style={s.scrollView}
contentContainerStyle={{paddingBottom: 60 + insets.bottom}}
showsVerticalScrollIndicator={false}>
{/* General */}
<Animated.View entering={FadeInDown.duration(400).delay(100)}>
<Text style={s.sectionTitle}>{t('settings.general')}</Text>
<View style={s.sectionCard}>
<SettingsRow
theme={theme}
icon="currency-inr"
label={t('settings.baseCurrency')}
value={baseCurrency}
onPress={() => currencySheetRef.current?.present()}
/>
<View style={s.divider} />
<SettingsRow
theme={theme}
icon="translate"
iconColor="#7E57C2"
label={t('settings.language')}
value="English"
/>
<View style={s.divider} />
<SettingsRow
theme={theme}
icon="theme-light-dark"
iconColor="#E65100"
label={t('settings.theme')}
value={themeSetting.charAt(0).toUpperCase() + themeSetting.slice(1)}
onPress={() => themeSheetRef.current?.present()}
/>
</View>
</Animated.View>
{/* Data */}
<Animated.View entering={FadeInDown.duration(400).delay(200)}>
<Text style={s.sectionTitle}>{t('settings.data')}</Text>
<View style={s.sectionCard}>
<SettingsRow
theme={theme}
icon="export"
iconColor={colors.success}
label={t('settings.exportData')}
onPress={() => Alert.alert('Coming Soon', 'Export functionality will be available in a future release.')}
/>
<View style={s.divider} />
<SettingsRow
theme={theme}
icon="import"
iconColor="#1E88E5"
label={t('settings.importData')}
onPress={() => Alert.alert('Coming Soon', 'Import functionality will be available in a future release.')}
/>
<View style={s.divider} />
<SettingsRow
theme={theme}
icon="delete-forever"
iconColor={colors.error}
label={t('settings.clearData')}
onPress={handleClearData}
destructive
/>
</View>
</Animated.View>
{/* About */}
<Animated.View entering={FadeInDown.duration(400).delay(300)}>
<Text style={s.sectionTitle}>{t('settings.about')}</Text>
<View style={s.sectionCard}>
<SettingsRow
theme={theme}
icon="information"
iconColor="#7E57C2"
label={t('settings.version')}
value="0.1.0 Alpha"
/>
</View>
</Animated.View>
<Text style={s.footer}>
{t('settings.appName')} - Made by WebArk
</Text>
</ScrollView>
{/* Currency Selection Bottom Sheet */}
<CustomBottomSheet
ref={currencySheetRef}
title={t('settings.baseCurrency')}
enableDynamicSizing
snapPoints={['40%']}>
{CURRENCIES.map(c => (
<Pressable
key={c.value}
style={[
s.selectionRow,
c.value === baseCurrency && {backgroundColor: colors.primaryContainer},
]}
onPress={() => {
triggerHaptic('selection');
setBaseCurrency(c.value);
currencySheetRef.current?.dismiss();
}}>
<Icon
name={c.icon}
size={20}
color={c.value === baseCurrency ? colors.primary : colors.onSurfaceVariant}
/>
<Text
style={[
s.selectionLabel,
c.value === baseCurrency && {color: colors.onPrimaryContainer, fontWeight: '600'},
]}>
{c.label}
</Text>
{c.value === baseCurrency && (
<Icon name="check" size={20} color={colors.primary} />
)}
</Pressable>
))}
</CustomBottomSheet>
{/* Theme Selection Bottom Sheet */}
<CustomBottomSheet
ref={themeSheetRef}
title={t('settings.theme')}
enableDynamicSizing
snapPoints={['35%']}>
{THEMES.map(th => (
<Pressable
key={th.value}
style={[
s.selectionRow,
th.value === themeSetting && {backgroundColor: colors.primaryContainer},
]}
onPress={() => {
triggerHaptic('selection');
setTheme(th.value);
themeSheetRef.current?.dismiss();
}}>
<Icon
name={th.icon}
size={20}
color={th.value === themeSetting ? colors.primary : colors.onSurfaceVariant}
/>
<Text
style={[
s.selectionLabel,
th.value === themeSetting && {color: colors.onPrimaryContainer, fontWeight: '600'},
]}>
{th.label}
</Text>
{th.value === themeSetting && (
<Icon name="check" size={20} color={colors.primary} />
)}
</Pressable>
))}
</CustomBottomSheet>
</SafeAreaView>
);
};
export default SettingsScreen;
// ─── Styles ────────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
screen: {flex: 1, backgroundColor: colors.background},
header: {
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.sm,
},
headerTitle: {
...typography.headlineMedium,
color: colors.onSurface,
fontWeight: '700',
},
scrollView: {flex: 1, paddingHorizontal: spacing.xl},
sectionTitle: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginTop: spacing.xxl,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
sectionCard: {
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.large,
overflow: 'hidden',
...elevation.level1,
},
divider: {
height: 1,
backgroundColor: colors.outlineVariant + '30',
marginLeft: 64,
},
footer: {
textAlign: 'center',
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginTop: spacing.xxxl,
marginBottom: spacing.xxxl + 8,
},
selectionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg,
borderRadius: shape.medium,
marginBottom: spacing.xs,
},
selectionLabel: {
flex: 1,
...typography.bodyLarge,
color: colors.onSurface,
},
});
}

5
src/screens/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export {default as DashboardScreen} from './DashboardScreen';
export {default as ModernDashboard} from './ModernDashboard';
export {default as ExpensesScreen} from './ExpensesScreen';
export {default as NetWorthScreen} from './NetWorthScreen';
export {default as SettingsScreen} from './SettingsScreen';