mirror of
https://github.com/arkorty/Expensso.git
synced 2026-03-18 00:47:11 +00:00
init
This commit is contained in:
429
src/screens/DashboardScreen.tsx
Normal file
429
src/screens/DashboardScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
552
src/screens/ExpensesScreen.tsx
Normal file
552
src/screens/ExpensesScreen.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
213
src/screens/ModernDashboard.tsx
Normal file
213
src/screens/ModernDashboard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
633
src/screens/NetWorthScreen.tsx
Normal file
633
src/screens/NetWorthScreen.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
379
src/screens/SettingsScreen.tsx
Normal file
379
src/screens/SettingsScreen.tsx
Normal 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
5
src/screens/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user