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,433 @@
/**
* CustomBottomSheet — replaces ALL system Alert/Modal patterns.
*
* Built on @gorhom/bottom-sheet with MD3 styling, drag-handle,
* scrim overlay, and smooth reanimated transitions.
*
* Usage:
* <CustomBottomSheet ref={sheetRef} snapPoints={['50%']}>
* <BottomSheetContent />
* </CustomBottomSheet>
*
* Imperative API:
* sheetRef.current?.present() open
* sheetRef.current?.dismiss() close
*/
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Pressable,
} from 'react-native';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetView,
type BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
const hapticOptions = {enableVibrateFallback: true, ignoreAndroidSystemSettings: false};
export const triggerHaptic = (type: 'impactLight' | 'impactMedium' | 'selection' | 'notificationSuccess' = 'impactLight') =>
ReactNativeHapticFeedback.trigger(type, hapticOptions);
// ─── Public Handle ───────────────────────────────────────────────────
export interface CustomBottomSheetHandle {
present: () => void;
dismiss: () => void;
}
// ─── Props ───────────────────────────────────────────────────────────
export interface CustomBottomSheetProps {
/** Title displayed in the sheet header */
title?: string;
/** Snap-point percentages or absolute values */
snapPoints?: (string | number)[];
/** Callback when the sheet is fully closed */
onDismiss?: () => void;
/** Whether to wrap children in a ScrollView (default: true) */
scrollable?: boolean;
/** Header left action (e.g. Cancel) */
headerLeft?: {label: string; onPress: () => void};
/** Header right action (e.g. Save) */
headerRight?: {label: string; onPress: () => void; color?: string};
/** Content */
children: React.ReactNode;
/** Enable dynamic sizing instead of snapPoints */
enableDynamicSizing?: boolean;
}
// ─── Component ───────────────────────────────────────────────────────
const CustomBottomSheetInner = forwardRef<CustomBottomSheetHandle, CustomBottomSheetProps>(
(
{
title,
snapPoints: snapPointsProp,
onDismiss,
scrollable = true,
headerLeft,
headerRight,
children,
enableDynamicSizing = false,
},
ref,
) => {
const theme = useTheme();
const s = makeStyles(theme);
const sheetRef = useRef<BottomSheet>(null);
const snapPoints = useMemo(() => snapPointsProp ?? ['60%'], [snapPointsProp]);
// Imperative handle
useImperativeHandle(ref, () => ({
present: () => {
triggerHaptic('impactMedium');
sheetRef.current?.snapToIndex(0);
},
dismiss: () => {
triggerHaptic('impactLight');
sheetRef.current?.close();
},
}));
// Backdrop with scrim
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.4}
pressBehavior="close"
/>
),
[],
);
// Handle indicator
const renderHandle = useCallback(
() => (
<View style={s.handleContainer}>
<View style={s.handle} />
</View>
),
[s],
);
const Wrapper = scrollable ? BottomSheetScrollView : BottomSheetView;
return (
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={enableDynamicSizing ? undefined : snapPoints}
enableDynamicSizing={enableDynamicSizing}
enablePanDownToClose
onClose={onDismiss}
backdropComponent={renderBackdrop}
handleComponent={renderHandle}
backgroundStyle={s.background}
style={s.container}
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize">
{/* Sheet Header */}
{(title || headerLeft || headerRight) && (
<View style={s.header}>
{headerLeft ? (
<TouchableOpacity onPress={headerLeft.onPress} hitSlop={8}>
<Text style={s.headerLeftText}>{headerLeft.label}</Text>
</TouchableOpacity>
) : (
<View style={s.headerPlaceholder} />
)}
{title ? <Text style={s.headerTitle}>{title}</Text> : <View />}
{headerRight ? (
<TouchableOpacity onPress={headerRight.onPress} hitSlop={8}>
<Text
style={[
s.headerRightText,
headerRight.color ? {color: headerRight.color} : undefined,
]}>
{headerRight.label}
</Text>
</TouchableOpacity>
) : (
<View style={s.headerPlaceholder} />
)}
</View>
)}
{/* Sheet Body */}
<Wrapper
style={s.body}
contentContainerStyle={s.bodyContent}
showsVerticalScrollIndicator={false}>
{children}
</Wrapper>
</BottomSheet>
);
},
);
CustomBottomSheetInner.displayName = 'CustomBottomSheet';
export const CustomBottomSheet = CustomBottomSheetInner;
// ─── Styles ──────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, shape, spacing} = theme;
return StyleSheet.create({
container: {
zIndex: 999,
},
background: {
backgroundColor: colors.surfaceContainerLow,
borderTopLeftRadius: shape.extraLarge,
borderTopRightRadius: shape.extraLarge,
},
handleContainer: {
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
handle: {
width: 32,
height: 4,
borderRadius: 2,
backgroundColor: colors.onSurfaceVariant,
opacity: 0.4,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xl,
paddingTop: spacing.sm,
paddingBottom: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.outlineVariant,
},
headerTitle: {
...typography.titleMedium,
color: colors.onSurface,
fontWeight: '600',
},
headerLeftText: {
...typography.labelLarge,
color: colors.onSurfaceVariant,
},
headerRightText: {
...typography.labelLarge,
color: colors.primary,
fontWeight: '600',
},
headerPlaceholder: {
width: 48,
},
body: {
flex: 1,
},
bodyContent: {
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.xxxl + 20,
},
});
}
// ─── Convenience Sub-Components ──────────────────────────────────────
/**
* MD3-styled text input for use inside bottom sheets.
*/
export interface BottomSheetInputProps {
label: string;
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
keyboardType?: 'default' | 'decimal-pad' | 'number-pad' | 'email-address';
error?: string;
multiline?: boolean;
prefix?: string;
autoFocus?: boolean;
}
import {TextInput} from 'react-native';
export const BottomSheetInput: React.FC<BottomSheetInputProps> = ({
label,
value,
onChangeText,
placeholder,
keyboardType = 'default',
error,
multiline = false,
prefix,
autoFocus = false,
}) => {
const theme = useTheme();
const {colors, typography, shape, spacing} = theme;
const [focused, setFocused] = React.useState(false);
const borderColor = error
? colors.error
: focused
? colors.primary
: colors.outline;
return (
<View style={{marginBottom: spacing.lg}}>
<Text
style={{
...typography.bodySmall,
color: error ? colors.error : focused ? colors.primary : colors.onSurfaceVariant,
marginBottom: spacing.xs,
}}>
{label}
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
borderWidth: focused ? 2 : 1,
borderColor,
borderRadius: shape.small,
backgroundColor: colors.surfaceContainerLowest,
paddingHorizontal: spacing.md,
minHeight: multiline ? 80 : 48,
}}>
{prefix && (
<Text
style={{
...typography.bodyLarge,
color: colors.onSurfaceVariant,
marginRight: spacing.xs,
}}>
{prefix}
</Text>
)}
<TextInput
style={{
flex: 1,
...typography.bodyLarge,
color: colors.onSurface,
paddingVertical: spacing.sm,
textAlignVertical: multiline ? 'top' : 'center',
}}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.onSurfaceVariant + '80'}
keyboardType={keyboardType}
multiline={multiline}
autoFocus={autoFocus}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
</View>
{error && (
<Text
style={{
...typography.bodySmall,
color: colors.error,
marginTop: spacing.xs,
}}>
{error}
</Text>
)}
</View>
);
};
/**
* MD3 chip selector row for use inside bottom sheets.
*/
export interface BottomSheetChipSelectorProps<T extends string> {
label?: string;
options: {value: T; label: string; icon?: string}[];
selected: T;
onSelect: (value: T) => void;
}
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
export function BottomSheetChipSelector<T extends string>({
label,
options,
selected,
onSelect,
}: BottomSheetChipSelectorProps<T>) {
const theme = useTheme();
const {colors, typography, shape, spacing} = theme;
return (
<View style={{marginBottom: spacing.lg}}>
{label && (
<Text
style={{
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginBottom: spacing.sm,
}}>
{label}
</Text>
)}
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm}}>
{options.map(opt => {
const isSelected = opt.value === selected;
return (
<Pressable
key={opt.value}
onPress={() => {
triggerHaptic('selection');
onSelect(opt.value);
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: shape.small,
borderWidth: 1,
borderColor: isSelected ? colors.primary : colors.outlineVariant,
backgroundColor: isSelected
? colors.primaryContainer
: colors.surfaceContainerLowest,
}}>
{opt.icon && (
<Icon
name={opt.icon}
size={16}
color={isSelected ? colors.primary : colors.onSurfaceVariant}
style={{marginRight: spacing.xs}}
/>
)}
<Text
style={{
...typography.labelMedium,
color: isSelected ? colors.onPrimaryContainer : colors.onSurfaceVariant,
}}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import {View, Text, StyleSheet} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {useTheme} from '../theme';
interface EmptyStateProps {
icon: string;
title: string;
subtitle?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({icon, title, subtitle}) => {
const {colors} = useTheme();
return (
<View style={styles.container}>
<Icon name={icon} size={56} color={colors.onSurfaceVariant} />
<Text style={[styles.title, {color: colors.onSurfaceVariant}]}>{title}</Text>
{subtitle ? <Text style={[styles.subtitle, {color: colors.onSurfaceVariant}]}>{subtitle}</Text> : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
paddingHorizontal: 32,
},
title: {
fontSize: 17,
fontWeight: '600',
marginTop: 16,
textAlign: 'center',
},
subtitle: {
fontSize: 14,
marginTop: 6,
textAlign: 'center',
lineHeight: 20,
},
});

View File

@@ -0,0 +1,54 @@
import React from 'react';
import {View, Text, StyleSheet, Pressable, ViewStyle} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {useTheme} from '../theme';
interface SectionHeaderProps {
title: string;
actionLabel?: string;
onAction?: () => void;
style?: ViewStyle;
}
export const SectionHeader: React.FC<SectionHeaderProps> = ({
title,
actionLabel,
onAction,
style,
}) => {
const {colors} = useTheme();
return (
<View style={[styles.container, style]}>
<Text style={[styles.title, {color: colors.onSurface}]}>{title}</Text>
{actionLabel && onAction ? (
<Pressable onPress={onAction} style={styles.action} hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
<Text style={[styles.actionLabel, {color: colors.primary}]}>{actionLabel}</Text>
<Icon name="chevron-right" size={16} color={colors.primary} />
</Pressable>
) : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: 24,
paddingBottom: 12,
},
title: {
fontSize: 18,
fontWeight: '700',
},
action: {
flexDirection: 'row',
alignItems: 'center',
},
actionLabel: {
fontSize: 15,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,69 @@
import React from 'react';
import {View, Text, StyleSheet, ViewStyle} from 'react-native';
import {useTheme} from '../theme';
interface SummaryCardProps {
title: string;
value: string;
subtitle?: string;
icon?: React.ReactNode;
valueColor?: string;
style?: ViewStyle;
}
export const SummaryCard: React.FC<SummaryCardProps> = ({
title,
value,
subtitle,
icon,
valueColor,
style,
}) => {
const {colors, typography, elevation, shape, spacing} = useTheme();
return (
<View style={[styles.card, {backgroundColor: colors.surfaceContainerLow, ...elevation.level1}, style]}>
<View style={styles.cardHeader}>
{icon && <View style={styles.iconContainer}>{icon}</View>}
<Text style={[styles.cardTitle, {color: colors.onSurfaceVariant}]}>{title}</Text>
</View>
<Text style={[styles.cardValue, {color: colors.onSurface}, valueColor ? {color: valueColor} : undefined]} numberOfLines={1} adjustsFontSizeToFit>
{value}
</Text>
{subtitle ? <Text style={[styles.cardSubtitle, {color: colors.onSurfaceVariant}]}>{subtitle}</Text> : null}
</View>
);
};
const styles = StyleSheet.create({
card: {
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
iconContainer: {
marginRight: 8,
},
cardTitle: {
fontSize: 14,
fontWeight: '500',
letterSpacing: 0.2,
},
cardValue: {
fontSize: 26,
fontWeight: '700',
marginBottom: 2,
},
cardSubtitle: {
fontSize: 13,
marginTop: 2,
},
});

View File

@@ -0,0 +1,106 @@
import React from 'react';
import {View, Text, StyleSheet, Pressable} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {Transaction} from '../types';
import {formatCurrency} from '../utils';
import {useSettingsStore} from '../store';
import {useTheme} from '../theme';
interface TransactionItemProps {
transaction: Transaction;
onPress?: (transaction: Transaction) => void;
}
export const TransactionItem: React.FC<TransactionItemProps> = ({
transaction,
onPress,
}) => {
const baseCurrency = useSettingsStore(s => s.baseCurrency);
const {colors} = useTheme();
const isExpense = transaction.type === 'expense';
return (
<Pressable
style={[styles.container, {backgroundColor: colors.surface}]}
onPress={() => onPress?.(transaction)}
android_ripple={{color: colors.primary + '12'}}>
<View
style={[
styles.iconCircle,
{backgroundColor: (transaction.categoryColor || '#95A5A6') + '18'},
]}>
<Icon
name={transaction.categoryIcon || 'dots-horizontal'}
size={22}
color={transaction.categoryColor || '#95A5A6'}
/>
</View>
<View style={styles.details}>
<Text style={[styles.categoryName, {color: colors.onSurface}]} numberOfLines={1}>
{transaction.categoryName || 'Uncategorized'}
</Text>
<Text style={[styles.meta, {color: colors.onSurfaceVariant}]} numberOfLines={1}>
{transaction.paymentMethod}
{transaction.note ? ` · ${transaction.note}` : ''}
</Text>
</View>
<View style={styles.amountContainer}>
<Text
style={[
styles.amount,
{color: isExpense ? colors.error : colors.success},
]}>
{isExpense ? '-' : '+'}
{formatCurrency(transaction.amount, baseCurrency)}
</Text>
<Text style={[styles.date, {color: colors.onSurfaceVariant}]}>
{new Date(transaction.date).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
})}
</Text>
</View>
</Pressable>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 18,
},
iconCircle: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
details: {
flex: 1,
marginLeft: 12,
},
categoryName: {
fontSize: 16,
fontWeight: '600',
},
meta: {
fontSize: 13,
marginTop: 2,
},
amountContainer: {
alignItems: 'flex-end',
},
amount: {
fontSize: 16,
fontWeight: '700',
},
date: {
fontSize: 12,
marginTop: 2,
},
});

View File

@@ -0,0 +1,164 @@
/**
* AssetChipRow — Horizontal scrolling chip/card row
* showing quick totals for Bank, Stocks, Gold, Debt, etc.
*/
import React from 'react';
import {View, Text, StyleSheet, ScrollView} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeInRight} from 'react-native-reanimated';
import {useTheme} from '../../theme';
import type {MD3Theme} from '../../theme';
import {formatCompact} from '../../utils';
import type {Asset, Liability, Currency} from '../../types';
interface AssetChipRowProps {
assets: Asset[];
liabilities: Liability[];
currency: Currency;
}
const ASSET_ICONS: Record<string, {icon: string; color: string}> = {
Bank: {icon: 'bank', color: '#1E88E5'},
Stocks: {icon: 'chart-line', color: '#7E57C2'},
Gold: {icon: 'gold', color: '#D4AF37'},
EPF: {icon: 'shield-account', color: '#00ACC1'},
'Real Estate': {icon: 'home-city', color: '#8D6E63'},
'Mutual Funds': {icon: 'chart-areaspline', color: '#26A69A'},
'Fixed Deposit': {icon: 'safe', color: '#3949AB'},
PPF: {icon: 'piggy-bank', color: '#43A047'},
Other: {icon: 'dots-horizontal', color: '#78909C'},
};
export const AssetChipRow: React.FC<AssetChipRowProps> = ({
assets,
liabilities,
currency,
}) => {
const theme = useTheme();
const s = makeStyles(theme);
// Group assets by type and sum values
const groupedAssets = assets.reduce<Record<string, number>>((acc, a) => {
acc[a.type] = (acc[a.type] || 0) + a.currentValue;
return acc;
}, {});
// Sum total liabilities
const totalDebt = liabilities.reduce((sum, l) => sum + l.outstandingAmount, 0);
const chips: {label: string; value: number; icon: string; iconColor: string; isDebt?: boolean}[] = [];
Object.entries(groupedAssets).forEach(([type, value]) => {
const visual = ASSET_ICONS[type] || ASSET_ICONS.Other;
chips.push({
label: type,
value,
icon: visual.icon,
iconColor: visual.color,
});
});
if (totalDebt > 0) {
chips.push({
label: 'Debt',
value: totalDebt,
icon: 'credit-card-clock',
iconColor: theme.colors.error,
isDebt: true,
});
}
if (chips.length === 0) return null;
return (
<View style={s.container}>
<Text style={s.sectionLabel}>Portfolio Breakdown</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={s.scrollContent}>
{chips.map((chip, idx) => (
<Animated.View
key={chip.label}
entering={FadeInRight.delay(idx * 80).duration(400).springify()}>
<View
style={[
s.chip,
chip.isDebt && {borderColor: theme.colors.errorContainer},
]}>
<View
style={[
s.chipIconBg,
{backgroundColor: chip.iconColor + '1A'},
]}>
<Icon name={chip.icon} size={18} color={chip.iconColor} />
</View>
<Text style={s.chipLabel} numberOfLines={1}>
{chip.label}
</Text>
<Text
style={[
s.chipValue,
chip.isDebt && {color: theme.colors.error},
]}
numberOfLines={1}>
{chip.isDebt ? '-' : ''}
{formatCompact(chip.value, currency)}
</Text>
</View>
</Animated.View>
))}
</ScrollView>
</View>
);
};
function makeStyles(theme: MD3Theme) {
const {colors, typography, shape, spacing} = theme;
return StyleSheet.create({
container: {
marginTop: spacing.xl,
},
sectionLabel: {
...typography.labelMedium,
color: colors.onSurfaceVariant,
letterSpacing: 0.8,
textTransform: 'uppercase',
marginLeft: spacing.xl,
marginBottom: spacing.sm,
},
scrollContent: {
paddingHorizontal: spacing.xl,
gap: spacing.sm,
},
chip: {
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.medium,
borderWidth: 1,
borderColor: colors.outlineVariant,
padding: spacing.md,
minWidth: 110,
alignItems: 'flex-start',
},
chipIconBg: {
width: 32,
height: 32,
borderRadius: shape.small,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.sm,
},
chipLabel: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
marginBottom: 2,
},
chipValue: {
...typography.titleSmall,
color: colors.onSurface,
fontWeight: '700',
},
});
}

View File

@@ -0,0 +1,277 @@
/**
* FinancialHealthGauges — Monthly Budget vs. Spent progress bars
* and savings rate indicator.
*/
import React from 'react';
import {View, Text, StyleSheet} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeInUp} from 'react-native-reanimated';
import {useTheme} from '../../theme';
import type {MD3Theme} from '../../theme';
import {formatCompact} from '../../utils';
import type {Currency} from '../../types';
interface FinancialHealthGaugesProps {
monthlyIncome: number;
monthlyExpense: number;
currency: Currency;
/** Optional monthly budget target. Defaults to income. */
monthlyBudget?: number;
}
export const FinancialHealthGauges: React.FC<FinancialHealthGaugesProps> = ({
monthlyIncome,
monthlyExpense,
currency,
monthlyBudget,
}) => {
const theme = useTheme();
const s = makeStyles(theme);
const budget = monthlyBudget || monthlyIncome;
const savingsRate =
monthlyIncome > 0
? (((monthlyIncome - monthlyExpense) / monthlyIncome) * 100).toFixed(0)
: '0';
const spentPercent = budget > 0 ? Math.min((monthlyExpense / budget) * 100, 100) : 0;
const remaining = Math.max(budget - monthlyExpense, 0);
const spentColor =
spentPercent > 90
? theme.colors.error
: spentPercent > 70
? theme.colors.warning
: theme.colors.success;
return (
<Animated.View entering={FadeInUp.duration(500).delay(400)} style={s.container}>
<Text style={s.sectionTitle}>Financial Health</Text>
<View style={s.cardsRow}>
{/* Budget Gauge */}
<View style={s.gaugeCard}>
<View style={s.gaugeHeader}>
<Icon name="chart-donut" size={18} color={spentColor} />
<Text style={s.gaugeLabel}>Budget</Text>
</View>
{/* Progress Bar */}
<View style={s.progressTrack}>
<View
style={[
s.progressFill,
{
width: `${spentPercent}%`,
backgroundColor: spentColor,
},
]}
/>
</View>
<View style={s.gaugeFooter}>
<Text style={s.gaugeSpent}>
{formatCompact(monthlyExpense, currency)} spent
</Text>
<Text style={s.gaugeRemaining}>
{formatCompact(remaining, currency)} left
</Text>
</View>
</View>
{/* Savings Rate Gauge */}
<View style={s.gaugeCard}>
<View style={s.gaugeHeader}>
<Icon
name="piggy-bank-outline"
size={18}
color={
Number(savingsRate) >= 20
? theme.colors.success
: theme.colors.warning
}
/>
<Text style={s.gaugeLabel}>Savings</Text>
</View>
<Text
style={[
s.savingsRate,
{
color:
Number(savingsRate) >= 20
? theme.colors.success
: Number(savingsRate) >= 0
? theme.colors.warning
: theme.colors.error,
},
]}>
{savingsRate}%
</Text>
<Text style={s.savingsSub}>of income saved</Text>
</View>
</View>
{/* Income vs Expense Bar */}
<View style={s.comparisonCard}>
<View style={s.comparisonRow}>
<View style={s.comparisonItem}>
<View style={[s.comparisonDot, {backgroundColor: theme.colors.success}]} />
<Text style={s.comparisonLabel}>Income</Text>
<Text style={[s.comparisonValue, {color: theme.colors.success}]}>
{formatCompact(monthlyIncome, currency)}
</Text>
</View>
<View style={s.comparisonItem}>
<View style={[s.comparisonDot, {backgroundColor: theme.colors.error}]} />
<Text style={s.comparisonLabel}>Expense</Text>
<Text style={[s.comparisonValue, {color: theme.colors.error}]}>
{formatCompact(monthlyExpense, currency)}
</Text>
</View>
</View>
{/* Dual progress bar */}
<View style={s.dualTrack}>
{monthlyIncome > 0 && (
<View
style={[
s.dualFill,
{
flex: monthlyIncome,
backgroundColor: theme.colors.success,
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
},
]}
/>
)}
{monthlyExpense > 0 && (
<View
style={[
s.dualFill,
{
flex: monthlyExpense,
backgroundColor: theme.colors.error,
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
},
]}
/>
)}
</View>
</View>
</Animated.View>
);
};
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
container: {
marginTop: spacing.xl,
marginHorizontal: spacing.xl,
},
sectionTitle: {
...typography.titleSmall,
color: colors.onSurface,
fontWeight: '600',
marginBottom: spacing.md,
},
cardsRow: {
flexDirection: 'row',
gap: spacing.md,
},
gaugeCard: {
flex: 1,
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.medium,
padding: spacing.lg,
...elevation.level1,
},
gaugeHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.md,
},
gaugeLabel: {
...typography.labelMedium,
color: colors.onSurfaceVariant,
},
progressTrack: {
height: 8,
backgroundColor: colors.surfaceContainerHighest,
borderRadius: 4,
overflow: 'hidden',
marginBottom: spacing.sm,
},
progressFill: {
height: 8,
borderRadius: 4,
},
gaugeFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
},
gaugeSpent: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
gaugeRemaining: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
savingsRate: {
...typography.headlineMedium,
fontWeight: '700',
marginBottom: 2,
},
savingsSub: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
comparisonCard: {
marginTop: spacing.md,
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.medium,
padding: spacing.lg,
...elevation.level1,
},
comparisonRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: spacing.md,
},
comparisonItem: {
alignItems: 'center',
gap: 2,
},
comparisonDot: {
width: 8,
height: 8,
borderRadius: 4,
marginBottom: 2,
},
comparisonLabel: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
comparisonValue: {
...typography.titleSmall,
fontWeight: '700',
},
dualTrack: {
height: 8,
flexDirection: 'row',
borderRadius: 4,
overflow: 'hidden',
backgroundColor: colors.surfaceContainerHighest,
gap: 2,
},
dualFill: {
height: 8,
},
});
}

View File

@@ -0,0 +1,200 @@
/**
* NetWorthHeroCard — Sophisticated header showing total net worth
* with a sparkline trend overlaying the background.
*/
import React, {useMemo} from 'react';
import {View, Text, StyleSheet} from 'react-native';
import {LineChart} from 'react-native-gifted-charts';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeInDown} from 'react-native-reanimated';
import {useTheme} from '../../theme';
import type {MD3Theme} from '../../theme';
import {formatCurrency, formatCompact} from '../../utils';
import type {Currency, NetWorthSnapshot} from '../../types';
interface NetWorthHeroCardProps {
netWorth: number;
totalAssets: number;
totalLiabilities: number;
currency: Currency;
history: NetWorthSnapshot[];
}
export const NetWorthHeroCard: React.FC<NetWorthHeroCardProps> = ({
netWorth,
totalAssets,
totalLiabilities,
currency,
history,
}) => {
const theme = useTheme();
const s = makeStyles(theme);
const sparklineData = useMemo(() => {
if (history.length < 2) return [];
return history.map(snap => ({
value: snap.netWorth,
}));
}, [history]);
const isPositive = netWorth >= 0;
return (
<Animated.View entering={FadeInDown.duration(500).springify()} style={s.card}>
{/* Sparkline Background */}
{sparklineData.length >= 2 && (
<View style={s.sparklineContainer}>
<LineChart
data={sparklineData}
curved
areaChart
hideDataPoints
hideYAxisText
hideAxesAndRules
color={
theme.isDark
? theme.colors.primaryContainer + '40'
: theme.colors.primary + '25'
}
startFillColor={
theme.isDark
? theme.colors.primaryContainer + '20'
: theme.colors.primary + '12'
}
endFillColor="transparent"
thickness={2}
width={280}
height={100}
adjustToWidth
isAnimated
animationDuration={800}
initialSpacing={0}
endSpacing={0}
yAxisOffset={Math.min(...sparklineData.map(d => d.value)) * 0.95}
/>
</View>
)}
{/* Content Overlay */}
<View style={s.content}>
<View style={s.labelRow}>
<Icon
name="chart-line-variant"
size={16}
color={theme.colors.onSurfaceVariant}
/>
<Text style={s.label}>NET WORTH</Text>
</View>
<Text
style={[
s.value,
{color: isPositive ? theme.colors.success : theme.colors.error},
]}
numberOfLines={1}
adjustsFontSizeToFit>
{formatCurrency(netWorth, currency)}
</Text>
{/* Asset / Liability Split */}
<View style={s.splitRow}>
<View style={s.splitItem}>
<View style={[s.splitDot, {backgroundColor: theme.colors.success}]} />
<View>
<Text style={s.splitLabel}>Assets</Text>
<Text style={[s.splitValue, {color: theme.colors.success}]}>
{formatCompact(totalAssets, currency)}
</Text>
</View>
</View>
<View style={s.splitDivider} />
<View style={s.splitItem}>
<View style={[s.splitDot, {backgroundColor: theme.colors.error}]} />
<View>
<Text style={s.splitLabel}>Liabilities</Text>
<Text style={[s.splitValue, {color: theme.colors.error}]}>
{formatCompact(totalLiabilities, currency)}
</Text>
</View>
</View>
</View>
</View>
</Animated.View>
);
};
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
card: {
marginHorizontal: spacing.xl,
marginTop: spacing.md,
borderRadius: shape.extraLarge,
backgroundColor: colors.surfaceContainerLow,
overflow: 'hidden',
...elevation.level3,
},
sparklineContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
opacity: 0.6,
overflow: 'hidden',
borderRadius: shape.extraLarge,
},
content: {
padding: spacing.xxl,
},
labelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.sm,
},
label: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
letterSpacing: 1.5,
},
value: {
...typography.displaySmall,
fontWeight: '700',
marginBottom: spacing.lg,
},
splitRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surfaceContainer,
borderRadius: shape.medium,
padding: spacing.md,
},
splitItem: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
justifyContent: 'center',
},
splitDot: {
width: 8,
height: 8,
borderRadius: 4,
},
splitLabel: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
splitValue: {
...typography.titleSmall,
fontWeight: '700',
},
splitDivider: {
width: 1,
height: 28,
backgroundColor: colors.outlineVariant,
},
});
}

View File

@@ -0,0 +1,223 @@
/**
* RecentActivityList — "Glassmorphism" elevated surface list of the
* last 5 transactions with high-quality category icons.
*/
import React from 'react';
import {View, Text, StyleSheet, Pressable} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeInUp} from 'react-native-reanimated';
import {useTheme} from '../../theme';
import type {MD3Theme} from '../../theme';
import {formatCurrency} from '../../utils';
import type {Transaction, Currency} from '../../types';
// Map common categories to premium Material icons
const CATEGORY_ICONS: Record<string, string> = {
Groceries: 'cart',
Rent: 'home',
Fuel: 'gas-station',
'Domestic Help': 'account-group',
Tiffin: 'food',
Utilities: 'lightning-bolt',
'Mobile Recharge': 'cellphone',
Transport: 'bus',
Shopping: 'shopping',
Medical: 'hospital-box',
Education: 'school',
Entertainment: 'movie',
'Dining Out': 'silverware-fork-knife',
Subscriptions: 'television-play',
Insurance: 'shield-check',
Salary: 'briefcase',
Freelance: 'laptop',
Investments: 'chart-line',
'Rental Income': 'home-city',
Dividends: 'cash-multiple',
UPI: 'contactless-payment',
};
interface RecentActivityListProps {
transactions: Transaction[];
currency: Currency;
onViewAll?: () => void;
}
export const RecentActivityList: React.FC<RecentActivityListProps> = ({
transactions,
currency,
onViewAll,
}) => {
const theme = useTheme();
const s = makeStyles(theme);
if (transactions.length === 0) {
return (
<View style={s.emptyContainer}>
<Icon name="receipt" size={48} color={theme.colors.onSurfaceVariant + '40'} />
<Text style={s.emptyText}>No recent transactions</Text>
<Text style={s.emptySubtext}>Start tracking to see activity here</Text>
</View>
);
}
return (
<Animated.View entering={FadeInUp.duration(500).delay(300)} style={s.container}>
<View style={s.headerRow}>
<Text style={s.title}>Recent Activity</Text>
{onViewAll && (
<Pressable onPress={onViewAll} hitSlop={8}>
<Text style={s.viewAll}>View All</Text>
</Pressable>
)}
</View>
<View style={s.glassCard}>
{transactions.slice(0, 5).map((txn, idx) => {
const isExpense = txn.type === 'expense';
const iconName =
CATEGORY_ICONS[txn.categoryName || ''] ||
txn.categoryIcon ||
'dots-horizontal';
const iconColor = txn.categoryColor || theme.colors.onSurfaceVariant;
return (
<View
key={txn.id}
style={[
s.txnRow,
idx < Math.min(transactions.length, 5) - 1 && s.txnRowBorder,
]}>
<View style={[s.iconCircle, {backgroundColor: iconColor + '14'}]}>
<Icon name={iconName} size={20} color={iconColor} />
</View>
<View style={s.txnDetails}>
<Text style={s.txnCategory} numberOfLines={1}>
{txn.categoryName || 'Uncategorized'}
</Text>
<Text style={s.txnMeta} numberOfLines={1}>
{txn.paymentMethod}
{txn.note ? ` · ${txn.note}` : ''}
</Text>
</View>
<View style={s.txnAmountCol}>
<Text
style={[
s.txnAmount,
{
color: isExpense
? theme.colors.error
: theme.colors.success,
},
]}>
{isExpense ? '-' : '+'}
{formatCurrency(txn.amount, currency)}
</Text>
<Text style={s.txnDate}>
{new Date(txn.date).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
})}
</Text>
</View>
</View>
);
})}
</View>
</Animated.View>
);
};
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
container: {
marginHorizontal: spacing.xl,
marginTop: spacing.xl,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
title: {
...typography.titleSmall,
color: colors.onSurface,
fontWeight: '600',
},
viewAll: {
...typography.labelMedium,
color: colors.primary,
},
glassCard: {
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.large,
borderWidth: 1,
borderColor: colors.outlineVariant + '40',
overflow: 'hidden',
...elevation.level2,
},
txnRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
},
txnRowBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.outlineVariant + '30',
},
iconCircle: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
txnDetails: {
flex: 1,
marginLeft: spacing.md,
},
txnCategory: {
...typography.bodyMedium,
color: colors.onSurface,
fontWeight: '500',
},
txnMeta: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginTop: 1,
},
txnAmountCol: {
alignItems: 'flex-end',
},
txnAmount: {
...typography.titleSmall,
fontWeight: '700',
},
txnDate: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
marginTop: 1,
},
emptyContainer: {
alignItems: 'center',
paddingVertical: spacing.xxxl + 16,
marginHorizontal: spacing.xl,
},
emptyText: {
...typography.bodyLarge,
color: colors.onSurfaceVariant,
marginTop: spacing.md,
},
emptySubtext: {
...typography.bodySmall,
color: colors.onSurfaceVariant + '80',
marginTop: spacing.xs,
},
});
}

View File

@@ -0,0 +1,181 @@
/**
* WealthDistributionChart — Donut chart showing asset allocation:
* Liquid (Bank, FD) vs Equity (Stocks, MF) vs Fixed (Gold, RE, EPF, PPF)
*/
import React, {useCallback, useMemo} from 'react';
import {View, Text, StyleSheet} from 'react-native';
import {PieChart} from 'react-native-gifted-charts';
import Animated, {FadeInUp} from 'react-native-reanimated';
import {useTheme} from '../../theme';
import type {MD3Theme} from '../../theme';
import {formatCompact} from '../../utils';
import type {Asset, Currency} from '../../types';
interface WealthDistributionChartProps {
assets: Asset[];
currency: Currency;
}
const ALLOCATION_MAP: Record<string, string> = {
Bank: 'Liquid',
'Fixed Deposit': 'Liquid',
Stocks: 'Equity',
'Mutual Funds': 'Equity',
Gold: 'Fixed',
'Real Estate': 'Fixed',
EPF: 'Fixed',
PPF: 'Fixed',
Other: 'Other',
};
const ALLOCATION_COLORS: Record<string, {light: string; dark: string}> = {
Liquid: {light: '#1E88E5', dark: '#64B5F6'},
Equity: {light: '#7E57C2', dark: '#CE93D8'},
Fixed: {light: '#D4AF37', dark: '#FFD54F'},
Other: {light: '#78909C', dark: '#B0BEC5'},
};
export const WealthDistributionChart: React.FC<WealthDistributionChartProps> = ({
assets,
currency,
}) => {
const theme = useTheme();
const s = makeStyles(theme);
const {pieData, totalValue, segments} = useMemo(() => {
const groups: Record<string, number> = {};
let total = 0;
assets.forEach(a => {
const bucket = ALLOCATION_MAP[a.type] || 'Other';
groups[bucket] = (groups[bucket] || 0) + a.currentValue;
total += a.currentValue;
});
const segs = Object.entries(groups)
.filter(([_, v]) => v > 0)
.sort((a, b) => b[1] - a[1])
.map(([name, value]) => ({
name,
value,
percentage: total > 0 ? ((value / total) * 100).toFixed(1) : '0',
color:
ALLOCATION_COLORS[name]?.[theme.isDark ? 'dark' : 'light'] || '#78909C',
}));
const pie = segs.map((seg, idx) => ({
value: seg.value,
color: seg.color,
text: `${seg.percentage}%`,
focused: idx === 0,
}));
return {pieData: pie, totalValue: total, segments: segs};
}, [assets, theme.isDark]);
const CenterLabel = useCallback(() => (
<View style={s.centerLabel}>
<Text style={s.centerValue}>
{formatCompact(totalValue, currency)}
</Text>
<Text style={s.centerSubtitle}>Total</Text>
</View>
), [totalValue, currency, s]);
if (pieData.length === 0) return null;
return (
<Animated.View entering={FadeInUp.duration(500).delay(200)} style={s.card}>
<Text style={s.title}>Wealth Distribution</Text>
<View style={s.chartRow}>
<PieChart
data={pieData}
donut
innerRadius={48}
radius={72}
innerCircleColor={theme.colors.surfaceContainerLow}
centerLabelComponent={CenterLabel}
/>
<View style={s.legend}>
{segments.map(seg => (
<View key={seg.name} style={s.legendItem}>
<View style={[s.legendDot, {backgroundColor: seg.color}]} />
<View style={s.legendText}>
<Text style={s.legendName}>{seg.name}</Text>
<Text style={s.legendValue}>
{formatCompact(seg.value, currency)} · {seg.percentage}%
</Text>
</View>
</View>
))}
</View>
</View>
</Animated.View>
);
};
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
card: {
marginHorizontal: spacing.xl,
marginTop: spacing.xl,
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.large,
padding: spacing.xl,
...elevation.level1,
},
title: {
...typography.titleSmall,
color: colors.onSurface,
fontWeight: '600',
marginBottom: spacing.lg,
},
chartRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xl,
},
centerLabel: {
alignItems: 'center',
},
centerValue: {
...typography.titleSmall,
color: colors.onSurface,
fontWeight: '700',
},
centerSubtitle: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
legend: {
flex: 1,
gap: spacing.md,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
legendDot: {
width: 10,
height: 10,
borderRadius: 5,
},
legendText: {
flex: 1,
},
legendName: {
...typography.labelMedium,
color: colors.onSurface,
},
legendValue: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
},
});
}

View File

@@ -0,0 +1,5 @@
export {NetWorthHeroCard} from './NetWorthHeroCard';
export {AssetChipRow} from './AssetChipRow';
export {WealthDistributionChart} from './WealthDistributionChart';
export {RecentActivityList} from './RecentActivityList';
export {FinancialHealthGauges} from './FinancialHealthGauges';

11
src/components/index.ts Normal file
View File

@@ -0,0 +1,11 @@
export {SummaryCard} from './SummaryCard';
export {TransactionItem} from './TransactionItem';
export {EmptyState} from './EmptyState';
export {SectionHeader} from './SectionHeader';
export {
CustomBottomSheet,
BottomSheetInput,
BottomSheetChipSelector,
triggerHaptic,
} from './CustomBottomSheet';
export type {CustomBottomSheetHandle} from './CustomBottomSheet';

142
src/constants/index.ts Normal file
View File

@@ -0,0 +1,142 @@
import {Category, ExchangeRates, PaymentMethod} from '../types';
// ─── Default Expense Categories (Indian Context) ────────────────────
export const DEFAULT_EXPENSE_CATEGORIES: Omit<Category, 'id' | 'createdAt'>[] = [
{name: 'Rent', icon: 'home', color: '#E74C3C', type: 'expense', isDefault: true},
{name: 'Groceries', icon: 'cart', color: '#2ECC71', type: 'expense', isDefault: true},
{name: 'Fuel', icon: 'gas-station', color: '#F39C12', type: 'expense', isDefault: true},
{name: 'Domestic Help', icon: 'account-group', color: '#9B59B6', type: 'expense', isDefault: true},
{name: 'Tiffin', icon: 'food', color: '#E67E22', type: 'expense', isDefault: true},
{name: 'Utilities', icon: 'lightning-bolt', color: '#3498DB', type: 'expense', isDefault: true},
{name: 'Mobile Recharge', icon: 'cellphone', color: '#1ABC9C', type: 'expense', isDefault: true},
{name: 'Transport', icon: 'bus', color: '#34495E', type: 'expense', isDefault: true},
{name: 'Shopping', icon: 'shopping', color: '#E91E63', type: 'expense', isDefault: true},
{name: 'Medical', icon: 'hospital-box', color: '#F44336', type: 'expense', isDefault: true},
{name: 'Education', icon: 'school', color: '#673AB7', type: 'expense', isDefault: true},
{name: 'Entertainment', icon: 'movie', color: '#FF9800', type: 'expense', isDefault: true},
{name: 'Dining Out', icon: 'silverware-fork-knife', color: '#795548', type: 'expense', isDefault: true},
{name: 'Subscriptions', icon: 'television-play', color: '#607D8B', type: 'expense', isDefault: true},
{name: 'Insurance', icon: 'shield-check', color: '#4CAF50', type: 'expense', isDefault: true},
{name: 'Other', icon: 'dots-horizontal', color: '#95A5A6', type: 'expense', isDefault: true},
];
export const DEFAULT_INCOME_CATEGORIES: Omit<Category, 'id' | 'createdAt'>[] = [
{name: 'Salary', icon: 'briefcase', color: '#27AE60', type: 'income', isDefault: true},
{name: 'Freelance', icon: 'laptop', color: '#2980B9', type: 'income', isDefault: true},
{name: 'Investments', icon: 'chart-line', color: '#8E44AD', type: 'income', isDefault: true},
{name: 'Rental Income', icon: 'home-city', color: '#D35400', type: 'income', isDefault: true},
{name: 'Dividends', icon: 'cash-multiple', color: '#16A085', type: 'income', isDefault: true},
{name: 'Other', icon: 'dots-horizontal', color: '#7F8C8D', type: 'income', isDefault: true},
];
// ─── Payment Methods ─────────────────────────────────────────────────
export const PAYMENT_METHODS: PaymentMethod[] = [
'UPI',
'Cash',
'Credit Card',
'Debit Card',
'Digital Wallet',
'Net Banking',
'Other',
];
// ─── Static Exchange Rates (fallback) ────────────────────────────────
export const STATIC_EXCHANGE_RATES: ExchangeRates = {
INR: 1,
USD: 84.5, // 1 USD = 84.5 INR
EUR: 91.2, // 1 EUR = 91.2 INR
};
// ─── Currency Symbols ────────────────────────────────────────────────
export const CURRENCY_SYMBOLS: Record<string, string> = {
INR: '₹',
USD: '$',
EUR: '€',
};
// ─── Theme Colors ────────────────────────────────────────────────────
export const COLORS = {
primary: '#0A84FF',
primaryDark: '#0066CC',
secondary: '#5856D6',
success: '#34C759',
danger: '#FF3B30',
warning: '#FF9500',
info: '#5AC8FA',
background: '#F2F2F7',
surface: '#FFFFFF',
surfaceVariant: '#F8F9FA',
card: '#FFFFFF',
text: '#000000',
textSecondary: '#6B7280',
textTertiary: '#9CA3AF',
textInverse: '#FFFFFF',
border: '#E5E7EB',
borderLight: '#F3F4F6',
divider: '#E5E7EB',
income: '#34C759',
expense: '#FF3B30',
asset: '#0A84FF',
liability: '#FF9500',
chartColors: [
'#0A84FF', '#34C759', '#FF9500', '#FF3B30',
'#5856D6', '#AF52DE', '#FF2D55', '#5AC8FA',
'#FFCC00', '#64D2FF',
],
};
export const DARK_COLORS: typeof COLORS = {
primary: '#0A84FF',
primaryDark: '#409CFF',
secondary: '#5E5CE6',
success: '#30D158',
danger: '#FF453A',
warning: '#FF9F0A',
info: '#64D2FF',
background: '#000000',
surface: '#1C1C1E',
surfaceVariant: '#2C2C2E',
card: '#1C1C1E',
text: '#FFFFFF',
textSecondary: '#EBEBF5',
textTertiary: '#636366',
textInverse: '#000000',
border: '#38383A',
borderLight: '#2C2C2E',
divider: '#38383A',
income: '#30D158',
expense: '#FF453A',
asset: '#0A84FF',
liability: '#FF9F0A',
chartColors: [
'#0A84FF', '#30D158', '#FF9F0A', '#FF453A',
'#5E5CE6', '#BF5AF2', '#FF375F', '#64D2FF',
'#FFD60A', '#64D2FF',
],
};
// ─── Date Formats ────────────────────────────────────────────────────
export const DATE_FORMATS = {
display: 'DD MMM YYYY',
displayShort: 'DD MMM',
month: 'MMM YYYY',
iso: 'YYYY-MM-DD',
time: 'hh:mm A',
full: 'DD MMM YYYY, hh:mm A',
};

167
src/db/database.ts Normal file
View File

@@ -0,0 +1,167 @@
import SQLite, {
SQLiteDatabase,
Transaction as SQLTransaction,
ResultSet,
} from 'react-native-sqlite-storage';
// Enable promise-based API
SQLite.enablePromise(true);
const DATABASE_NAME = 'expensso.db';
const DATABASE_VERSION = '1.0';
const DATABASE_DISPLAY_NAME = 'Expensso Database';
const DATABASE_SIZE = 200000;
let db: SQLiteDatabase | null = null;
// ─── Open / Get Database ─────────────────────────────────────────────
export async function getDatabase(): Promise<SQLiteDatabase> {
if (db) {
return db;
}
db = await SQLite.openDatabase({
name: DATABASE_NAME,
location: 'default',
});
await createTables(db);
return db;
}
// ─── Schema Creation ─────────────────────────────────────────────────
async function createTables(database: SQLiteDatabase): Promise<void> {
await database.transaction(async (tx: SQLTransaction) => {
// Categories table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT 'dots-horizontal',
color TEXT NOT NULL DEFAULT '#95A5A6',
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Transactions table (the ledger)
tx.executeSql(`
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'INR',
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
category_id TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'UPI',
note TEXT DEFAULT '',
date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);
`);
// Transaction impacts table (links transactions to net-worth entries)
tx.executeSql(`
CREATE TABLE IF NOT EXISTS transaction_impacts (
transaction_id TEXT PRIMARY KEY,
target_type TEXT NOT NULL CHECK(target_type IN ('asset', 'liability')),
target_id TEXT NOT NULL,
operation TEXT NOT NULL CHECK(operation IN ('add', 'subtract')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
);
`);
// Assets table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
current_value REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'INR',
note TEXT DEFAULT '',
last_updated TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Liabilities table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS liabilities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
outstanding_amount REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'INR',
interest_rate REAL NOT NULL DEFAULT 0,
emi_amount REAL NOT NULL DEFAULT 0,
note TEXT DEFAULT '',
last_updated TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Net-worth snapshots for historical tracking
tx.executeSql(`
CREATE TABLE IF NOT EXISTS net_worth_snapshots (
id TEXT PRIMARY KEY,
total_assets REAL NOT NULL DEFAULT 0,
total_liabilities REAL NOT NULL DEFAULT 0,
net_worth REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'INR',
snapshot_date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Indexes for performance
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transactions_type ON transactions(type);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transaction_impacts_target ON transaction_impacts(target_type, target_id);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON net_worth_snapshots(snapshot_date);
`);
});
}
// ─── Generic Helpers ─────────────────────────────────────────────────
export async function executeSql(
sql: string,
params: any[] = [],
): Promise<ResultSet> {
const database = await getDatabase();
const [result] = await database.executeSql(sql, params);
return result;
}
export function rowsToArray<T>(result: ResultSet): T[] {
const rows: T[] = [];
for (let i = 0; i < result.rows.length; i++) {
rows.push(result.rows.item(i) as T);
}
return rows;
}
// ─── Close Database ──────────────────────────────────────────────────
export async function closeDatabase(): Promise<void> {
if (db) {
await db.close();
db = null;
}
}

2
src/db/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export {getDatabase, executeSql, rowsToArray, closeDatabase} from './database';
export * from './queries';

445
src/db/queries.ts Normal file
View File

@@ -0,0 +1,445 @@
import {executeSql, rowsToArray} from './database';
import {
Category,
Transaction,
Asset,
Liability,
NetWorthSnapshot,
TransactionImpact,
NetWorthTargetType,
ImpactOperation,
} from '../types';
import {generateId} from '../utils';
import {DEFAULT_EXPENSE_CATEGORIES, DEFAULT_INCOME_CATEGORIES} from '../constants';
// ═══════════════════════════════════════════════════════════════════════
// CATEGORIES
// ═══════════════════════════════════════════════════════════════════════
export async function seedDefaultCategories(): Promise<void> {
const result = await executeSql('SELECT COUNT(*) as count FROM categories');
const count = result.rows.item(0).count;
if (count > 0) {return;}
const allCategories = [...DEFAULT_EXPENSE_CATEGORIES, ...DEFAULT_INCOME_CATEGORIES];
for (const cat of allCategories) {
await executeSql(
'INSERT INTO categories (id, name, icon, color, type, is_default) VALUES (?, ?, ?, ?, ?, ?)',
[generateId(), cat.name, cat.icon, cat.color, cat.type, cat.isDefault ? 1 : 0],
);
}
}
export async function getCategories(type?: 'income' | 'expense'): Promise<Category[]> {
let sql = 'SELECT id, name, icon, color, type, is_default as isDefault, created_at as createdAt FROM categories';
const params: any[] = [];
if (type) {
sql += ' WHERE type = ?';
params.push(type);
}
sql += ' ORDER BY is_default DESC, name ASC';
const result = await executeSql(sql, params);
return rowsToArray<Category>(result);
}
export async function insertCategory(cat: Omit<Category, 'id' | 'createdAt'>): Promise<string> {
const id = generateId();
await executeSql(
'INSERT INTO categories (id, name, icon, color, type, is_default) VALUES (?, ?, ?, ?, ?, ?)',
[id, cat.name, cat.icon, cat.color, cat.type, cat.isDefault ? 1 : 0],
);
return id;
}
// ═══════════════════════════════════════════════════════════════════════
// TRANSACTIONS
// ═══════════════════════════════════════════════════════════════════════
export async function getTransactions(options?: {
type?: 'income' | 'expense';
fromDate?: string;
toDate?: string;
categoryId?: string;
limit?: number;
offset?: number;
}): Promise<Transaction[]> {
let sql = `
SELECT
t.id, t.amount, t.currency, t.type, t.category_id as categoryId,
t.payment_method as paymentMethod, t.note, t.date,
t.created_at as createdAt, t.updated_at as updatedAt,
c.name as categoryName, c.icon as categoryIcon, c.color as categoryColor
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE 1=1
`;
const params: any[] = [];
if (options?.type) {
sql += ' AND t.type = ?';
params.push(options.type);
}
if (options?.fromDate) {
sql += ' AND t.date >= ?';
params.push(options.fromDate);
}
if (options?.toDate) {
sql += ' AND t.date <= ?';
params.push(options.toDate);
}
if (options?.categoryId) {
sql += ' AND t.category_id = ?';
params.push(options.categoryId);
}
sql += ' ORDER BY t.date DESC, t.created_at DESC';
if (options?.limit) {
sql += ' LIMIT ?';
params.push(options.limit);
}
if (options?.offset) {
sql += ' OFFSET ?';
params.push(options.offset);
}
const result = await executeSql(sql, params);
return rowsToArray<Transaction>(result);
}
export async function getTransactionById(id: string): Promise<Transaction | null> {
const result = await executeSql(
`SELECT
t.id, t.amount, t.currency, t.type, t.category_id as categoryId,
t.payment_method as paymentMethod, t.note, t.date,
t.created_at as createdAt, t.updated_at as updatedAt,
c.name as categoryName, c.icon as categoryIcon, c.color as categoryColor
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.id = ?`,
[id],
);
if (result.rows.length === 0) {
return null;
}
return result.rows.item(0) as Transaction;
}
export async function insertTransaction(
txn: Omit<Transaction, 'id' | 'createdAt' | 'updatedAt' | 'categoryName' | 'categoryIcon' | 'categoryColor'>,
): Promise<string> {
const id = generateId();
await executeSql(
`INSERT INTO transactions (id, amount, currency, type, category_id, payment_method, note, date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[id, txn.amount, txn.currency, txn.type, txn.categoryId, txn.paymentMethod, txn.note, txn.date],
);
return id;
}
export async function updateTransaction(
id: string,
txn: Partial<Omit<Transaction, 'id' | 'createdAt'>>,
): Promise<void> {
const fields: string[] = [];
const params: any[] = [];
if (txn.amount !== undefined) { fields.push('amount = ?'); params.push(txn.amount); }
if (txn.currency) { fields.push('currency = ?'); params.push(txn.currency); }
if (txn.type) { fields.push('type = ?'); params.push(txn.type); }
if (txn.categoryId) { fields.push('category_id = ?'); params.push(txn.categoryId); }
if (txn.paymentMethod) { fields.push('payment_method = ?'); params.push(txn.paymentMethod); }
if (txn.note !== undefined) { fields.push('note = ?'); params.push(txn.note); }
if (txn.date) { fields.push('date = ?'); params.push(txn.date); }
fields.push("updated_at = datetime('now')");
params.push(id);
await executeSql(`UPDATE transactions SET ${fields.join(', ')} WHERE id = ?`, params);
}
export async function deleteTransaction(id: string): Promise<void> {
await executeSql('DELETE FROM transactions WHERE id = ?', [id]);
}
export async function saveTransactionImpact(input: TransactionImpact): Promise<void> {
await executeSql(
`INSERT OR REPLACE INTO transaction_impacts (transaction_id, target_type, target_id, operation)
VALUES (?, ?, ?, ?)`,
[input.transactionId, input.targetType, input.targetId, input.operation],
);
}
export async function getTransactionImpact(transactionId: string): Promise<TransactionImpact | null> {
const result = await executeSql(
`SELECT transaction_id as transactionId, target_type as targetType, target_id as targetId, operation
FROM transaction_impacts
WHERE transaction_id = ?`,
[transactionId],
);
if (result.rows.length === 0) {
return null;
}
return result.rows.item(0) as TransactionImpact;
}
export async function deleteTransactionImpact(transactionId: string): Promise<void> {
await executeSql('DELETE FROM transaction_impacts WHERE transaction_id = ?', [transactionId]);
}
export async function applyTargetImpact(
targetType: NetWorthTargetType,
targetId: string,
operation: ImpactOperation,
amount: number,
): Promise<void> {
if (amount <= 0) {
return;
}
if (targetType === 'asset') {
if (operation === 'add') {
await executeSql(
`UPDATE assets
SET current_value = current_value + ?,
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
return;
}
await executeSql(
`UPDATE assets
SET current_value = MAX(current_value - ?, 0),
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
return;
}
if (operation === 'add') {
await executeSql(
`UPDATE liabilities
SET outstanding_amount = outstanding_amount + ?,
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
return;
}
await executeSql(
`UPDATE liabilities
SET outstanding_amount = MAX(outstanding_amount - ?, 0),
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
}
export async function reverseTargetImpact(impact: TransactionImpact, amount: number): Promise<void> {
const reverseOperation: ImpactOperation = impact.operation === 'add' ? 'subtract' : 'add';
await applyTargetImpact(impact.targetType, impact.targetId, reverseOperation, amount);
}
export async function getMonthlyTotals(
type: 'income' | 'expense',
year: number,
month: number,
): Promise<number> {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate =
month === 12
? `${year + 1}-01-01`
: `${year}-${String(month + 1).padStart(2, '0')}-01`;
const result = await executeSql(
'SELECT COALESCE(SUM(amount), 0) as total FROM transactions WHERE type = ? AND date >= ? AND date < ?',
[type, startDate, endDate],
);
return result.rows.item(0).total;
}
export async function getSpendingByCategory(
fromDate: string,
toDate: string,
): Promise<{categoryName: string; categoryColor: string; categoryIcon: string; total: number}[]> {
const result = await executeSql(
`SELECT c.name as categoryName, c.color as categoryColor, c.icon as categoryIcon,
SUM(t.amount) as total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.type = 'expense' AND t.date >= ? AND t.date < ?
GROUP BY t.category_id
ORDER BY total DESC`,
[fromDate, toDate],
);
return rowsToArray(result);
}
export async function getMonthlySpendingTrend(months: number = 6): Promise<{month: string; total: number}[]> {
const result = await executeSql(
`SELECT strftime('%Y-%m', date) as month, SUM(amount) as total
FROM transactions
WHERE type = 'expense'
AND date >= date('now', '-' || ? || ' months')
GROUP BY strftime('%Y-%m', date)
ORDER BY month ASC`,
[months],
);
return rowsToArray(result);
}
// ═══════════════════════════════════════════════════════════════════════
// ASSETS
// ═══════════════════════════════════════════════════════════════════════
export async function getAssets(): Promise<Asset[]> {
const result = await executeSql(
`SELECT id, name, type, current_value as currentValue, currency,
note, last_updated as lastUpdated, created_at as createdAt
FROM assets
ORDER BY current_value DESC`,
);
return rowsToArray<Asset>(result);
}
export async function insertAsset(asset: Omit<Asset, 'id' | 'createdAt' | 'lastUpdated'>): Promise<string> {
const id = generateId();
await executeSql(
'INSERT INTO assets (id, name, type, current_value, currency, note) VALUES (?, ?, ?, ?, ?, ?)',
[id, asset.name, asset.type, asset.currentValue, asset.currency, asset.note],
);
return id;
}
export async function updateAsset(id: string, asset: Partial<Omit<Asset, 'id' | 'createdAt'>>): Promise<void> {
const fields: string[] = [];
const params: any[] = [];
if (asset.name) { fields.push('name = ?'); params.push(asset.name); }
if (asset.type) { fields.push('type = ?'); params.push(asset.type); }
if (asset.currentValue !== undefined) { fields.push('current_value = ?'); params.push(asset.currentValue); }
if (asset.currency) { fields.push('currency = ?'); params.push(asset.currency); }
if (asset.note !== undefined) { fields.push('note = ?'); params.push(asset.note); }
fields.push("last_updated = datetime('now')");
params.push(id);
await executeSql(`UPDATE assets SET ${fields.join(', ')} WHERE id = ?`, params);
}
export async function deleteAsset(id: string): Promise<void> {
await executeSql('DELETE FROM assets WHERE id = ?', [id]);
}
export async function getTotalAssets(): Promise<number> {
const result = await executeSql('SELECT COALESCE(SUM(current_value), 0) as total FROM assets');
return result.rows.item(0).total;
}
// ═══════════════════════════════════════════════════════════════════════
// LIABILITIES
// ═══════════════════════════════════════════════════════════════════════
export async function getLiabilities(): Promise<Liability[]> {
const result = await executeSql(
`SELECT id, name, type, outstanding_amount as outstandingAmount, currency,
interest_rate as interestRate, emi_amount as emiAmount,
note, last_updated as lastUpdated, created_at as createdAt
FROM liabilities
ORDER BY outstanding_amount DESC`,
);
return rowsToArray<Liability>(result);
}
export async function insertLiability(
liability: Omit<Liability, 'id' | 'createdAt' | 'lastUpdated'>,
): Promise<string> {
const id = generateId();
await executeSql(
`INSERT INTO liabilities (id, name, type, outstanding_amount, currency, interest_rate, emi_amount, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
id, liability.name, liability.type, liability.outstandingAmount,
liability.currency, liability.interestRate, liability.emiAmount, liability.note,
],
);
return id;
}
export async function updateLiability(
id: string,
liability: Partial<Omit<Liability, 'id' | 'createdAt'>>,
): Promise<void> {
const fields: string[] = [];
const params: any[] = [];
if (liability.name) { fields.push('name = ?'); params.push(liability.name); }
if (liability.type) { fields.push('type = ?'); params.push(liability.type); }
if (liability.outstandingAmount !== undefined) { fields.push('outstanding_amount = ?'); params.push(liability.outstandingAmount); }
if (liability.currency) { fields.push('currency = ?'); params.push(liability.currency); }
if (liability.interestRate !== undefined) { fields.push('interest_rate = ?'); params.push(liability.interestRate); }
if (liability.emiAmount !== undefined) { fields.push('emi_amount = ?'); params.push(liability.emiAmount); }
if (liability.note !== undefined) { fields.push('note = ?'); params.push(liability.note); }
fields.push("last_updated = datetime('now')");
params.push(id);
await executeSql(`UPDATE liabilities SET ${fields.join(', ')} WHERE id = ?`, params);
}
export async function deleteLiability(id: string): Promise<void> {
await executeSql('DELETE FROM liabilities WHERE id = ?', [id]);
}
export async function getTotalLiabilities(): Promise<number> {
const result = await executeSql('SELECT COALESCE(SUM(outstanding_amount), 0) as total FROM liabilities');
return result.rows.item(0).total;
}
// ═══════════════════════════════════════════════════════════════════════
// NET WORTH SNAPSHOTS
// ═══════════════════════════════════════════════════════════════════════
export async function saveNetWorthSnapshot(
totalAssets: number,
totalLiabilities: number,
currency: string,
): Promise<string> {
const id = generateId();
const netWorth = totalAssets - totalLiabilities;
const today = new Date().toISOString().split('T')[0];
// Upsert: delete any existing snapshot for today then insert
await executeSql('DELETE FROM net_worth_snapshots WHERE snapshot_date = ?', [today]);
await executeSql(
`INSERT INTO net_worth_snapshots (id, total_assets, total_liabilities, net_worth, currency, snapshot_date)
VALUES (?, ?, ?, ?, ?, ?)`,
[id, totalAssets, totalLiabilities, netWorth, currency, today],
);
return id;
}
export async function getNetWorthHistory(months: number = 12): Promise<NetWorthSnapshot[]> {
const result = await executeSql(
`SELECT id, total_assets as totalAssets, total_liabilities as totalLiabilities,
net_worth as netWorth, currency, snapshot_date as snapshotDate,
created_at as createdAt
FROM net_worth_snapshots
WHERE snapshot_date >= date('now', '-' || ? || ' months')
ORDER BY snapshot_date ASC`,
[months],
);
return rowsToArray<NetWorthSnapshot>(result);
}

2
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export {useAppInit} from './useAppInit';
export {useThemeColors, useIsDarkTheme} from './useThemeColors';

53
src/hooks/useAppInit.ts Normal file
View File

@@ -0,0 +1,53 @@
import {useEffect, useState} from 'react';
import {getDatabase} from '../db/database';
import {seedDefaultCategories} from '../db/queries';
import {useSettingsStore} from '../store/settingsStore';
import {useExpenseStore} from '../store/expenseStore';
/**
* Hook to initialize the app: open DB, seed categories, hydrate settings.
* Returns `isReady` when all initialization is complete.
*/
export function useAppInit(): {isReady: boolean; error: string | null} {
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const hydrate = useSettingsStore(s => s.hydrate);
const initialize = useExpenseStore(s => s.initialize);
useEffect(() => {
let mounted = true;
async function init() {
try {
// 1. Open SQLite database (creates tables if needed)
await getDatabase();
// 2. Seed default categories
await seedDefaultCategories();
// 3. Hydrate settings from MMKV
hydrate();
// 4. Initialize expense store (load categories)
await initialize();
if (mounted) {
setIsReady(true);
}
} catch (err: any) {
console.error('App initialization failed:', err);
if (mounted) {
setError(err.message || 'Initialization failed');
}
}
}
init();
return () => {
mounted = false;
};
}, [hydrate, initialize]);
return {isReady, error};
}

View File

@@ -0,0 +1,32 @@
import {useColorScheme} from 'react-native';
import {useSettingsStore} from '../store';
import {COLORS, DARK_COLORS} from '../constants';
/**
* Returns the appropriate color palette based on the user's theme setting.
* - 'light' → always light
* - 'dark' → always dark
* - 'system' → follows the device setting
*/
export const useThemeColors = (): typeof COLORS => {
const theme = useSettingsStore(s => s.theme);
const systemScheme = useColorScheme(); // 'light' | 'dark' | null
if (theme === 'dark') return DARK_COLORS;
if (theme === 'light') return COLORS;
// system
return systemScheme === 'dark' ? DARK_COLORS : COLORS;
};
/**
* Returns true when the resolved theme is dark.
*/
export const useIsDarkTheme = (): boolean => {
const theme = useSettingsStore(s => s.theme);
const systemScheme = useColorScheme();
if (theme === 'dark') return true;
if (theme === 'light') return false;
return systemScheme === 'dark';
};

99
src/i18n/en.ts Normal file
View File

@@ -0,0 +1,99 @@
export default {
translation: {
// ─── Common ───
common: {
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
done: 'Done',
confirm: 'Confirm',
loading: 'Loading...',
noData: 'No data available',
error: 'Something went wrong',
retry: 'Retry',
},
// ─── Tabs ───
tabs: {
dashboard: 'Dashboard',
expenses: 'Expenses',
netWorth: 'Net Worth',
settings: 'Settings',
},
// ─── Dashboard ───
dashboard: {
title: 'Dashboard',
netWorth: 'Net Worth',
totalAssets: 'Total Assets',
totalLiabilities: 'Total Liabilities',
monthlySpending: 'Monthly Spending',
monthlyIncome: 'Monthly Income',
recentTransactions: 'Recent Transactions',
spendingTrends: 'Spending Trends',
viewAll: 'View All',
thisMonth: 'This Month',
lastMonth: 'Last Month',
},
// ─── Expenses ───
expenses: {
title: 'Expenses',
addExpense: 'Add Expense',
addIncome: 'Add Income',
amount: 'Amount',
category: 'Category',
paymentMethod: 'Payment Method',
date: 'Date',
note: 'Note (optional)',
noTransactions: 'No transactions yet',
startTracking: 'Start tracking your expenses',
},
// ─── Net Worth ───
netWorth: {
title: 'Net Worth',
assets: 'Assets',
liabilities: 'Liabilities',
addAsset: 'Add Asset',
addLiability: 'Add Liability',
assetName: 'Asset Name',
assetType: 'Asset Type',
currentValue: 'Current Value',
liabilityName: 'Liability Name',
liabilityType: 'Liability Type',
outstandingAmount: 'Outstanding Amount',
interestRate: 'Interest Rate (%)',
emiAmount: 'EMI Amount',
growth: 'Growth',
noAssets: 'No assets added yet',
noLiabilities: 'No liabilities added yet',
},
// ─── Settings ───
settings: {
title: 'Settings',
general: 'General',
baseCurrency: 'Base Currency',
language: 'Language',
theme: 'Theme',
data: 'Data',
exportData: 'Export Data',
importData: 'Import Data',
clearData: 'Clear All Data',
clearDataConfirm: 'Are you sure? This action cannot be undone.',
about: 'About',
version: 'Version',
appName: 'Expensso',
},
// ─── Currency ───
currency: {
INR: 'Indian Rupee (₹)',
USD: 'US Dollar ($)',
EUR: 'Euro (€)',
},
},
};

17
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import i18n from 'i18next';
import {initReactI18next} from 'react-i18next';
import en from './en';
i18n.use(initReactI18next).init({
compatibilityJSON: 'v4',
resources: {
en,
},
lng: 'en',
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import {NavigationContainer, DefaultTheme as NavDefaultTheme, DarkTheme as NavDarkTheme} from '@react-navigation/native';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {useTranslation} from 'react-i18next';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {
ModernDashboard,
ExpensesScreen,
NetWorthScreen,
SettingsScreen,
} from '../screens';
import {useTheme} from '../theme';
const Tab = createBottomTabNavigator();
const TAB_ICONS: Record<string, {focused: string; unfocused: string}> = {
Dashboard: {focused: 'view-dashboard', unfocused: 'view-dashboard-outline'},
Expenses: {focused: 'receipt', unfocused: 'receipt'},
NetWorth: {focused: 'chart-line', unfocused: 'chart-line'},
Settings: {focused: 'cog', unfocused: 'cog-outline'},
};
const AppNavigator: React.FC = () => {
const {t} = useTranslation();
const theme = useTheme();
const {colors, isDark} = theme;
const insets = useSafeAreaInsets();
const baseNavTheme = isDark ? NavDarkTheme : NavDefaultTheme;
const navigationTheme = {
...baseNavTheme,
colors: {
...baseNavTheme.colors,
background: colors.background,
card: colors.surfaceContainerLow,
text: colors.onSurface,
border: colors.outlineVariant,
primary: colors.primary,
},
};
return (
<NavigationContainer theme={navigationTheme}>
<Tab.Navigator
screenOptions={({route}) => ({
headerShown: false,
tabBarIcon: ({focused, color, size}) => {
const icons = TAB_ICONS[route.name];
const iconName = focused ? icons.focused : icons.unfocused;
return <Icon name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.onSurfaceVariant,
tabBarStyle: {
backgroundColor: colors.surfaceContainerLow,
borderTopColor: colors.outlineVariant + '40',
borderTopWidth: 1,
height: 60 + insets.bottom,
paddingBottom: insets.bottom,
},
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '600',
},
})}>
<Tab.Screen
name="Dashboard"
component={ModernDashboard}
options={{tabBarLabel: t('tabs.dashboard')}}
/>
<Tab.Screen
name="Expenses"
component={ExpensesScreen}
options={{tabBarLabel: t('tabs.expenses')}}
/>
<Tab.Screen
name="NetWorth"
component={NetWorthScreen}
options={{tabBarLabel: t('tabs.netWorth')}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{tabBarLabel: t('tabs.settings')}}
/>
</Tab.Navigator>
</NavigationContainer>
);
};
export default AppNavigator;

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';

257
src/store/expenseStore.ts Normal file
View File

@@ -0,0 +1,257 @@
import {create} from 'zustand';
import {
Transaction,
Category,
Currency,
TransactionType,
PaymentMethod,
NetWorthTargetType,
ImpactOperation,
} from '../types';
import {
getTransactions,
getTransactionById,
insertTransaction,
updateTransaction as updateTxnDb,
deleteTransaction as deleteTxnDb,
getMonthlyTotals,
getCategories,
seedDefaultCategories,
getSpendingByCategory,
getMonthlySpendingTrend,
insertAsset,
insertLiability,
saveTransactionImpact,
getTransactionImpact,
deleteTransactionImpact,
applyTargetImpact,
reverseTargetImpact,
} from '../db';
interface TransactionImpactInput {
targetType: NetWorthTargetType;
targetId?: string;
operation: ImpactOperation;
createNew?: {
name: string;
type: string;
note?: string;
};
}
interface ExpenseState {
transactions: Transaction[];
categories: Category[];
monthlyExpense: number;
monthlyIncome: number;
isLoading: boolean;
spendingByCategory: {categoryName: string; categoryColor: string; categoryIcon: string; total: number}[];
monthlyTrend: {month: string; total: number}[];
// Actions
initialize: () => Promise<void>;
loadTransactions: (options?: {
type?: TransactionType;
fromDate?: string;
toDate?: string;
limit?: number;
}) => Promise<void>;
addTransaction: (txn: {
amount: number;
currency: Currency;
type: TransactionType;
categoryId: string;
paymentMethod: PaymentMethod;
note: string;
date: string;
impact?: TransactionImpactInput;
}) => Promise<void>;
editTransaction: (id: string, txn: Partial<Transaction>, impact?: TransactionImpactInput) => Promise<void>;
removeTransaction: (id: string) => Promise<void>;
loadMonthlyStats: () => Promise<void>;
loadSpendingAnalytics: () => Promise<void>;
}
export const useExpenseStore = create<ExpenseState>((set, get) => ({
transactions: [],
categories: [],
monthlyExpense: 0,
monthlyIncome: 0,
isLoading: false,
spendingByCategory: [],
monthlyTrend: [],
initialize: async () => {
set({isLoading: true});
try {
await seedDefaultCategories();
const categories = await getCategories();
set({categories, isLoading: false});
} catch (error) {
console.error('Failed to initialize expense store:', error);
set({isLoading: false});
}
},
loadTransactions: async (options) => {
set({isLoading: true});
try {
const transactions = await getTransactions(options);
set({transactions, isLoading: false});
} catch (error) {
console.error('Failed to load transactions:', error);
set({isLoading: false});
}
},
addTransaction: async (txn) => {
const {impact, ...transactionPayload} = txn;
const transactionId = await insertTransaction(transactionPayload);
if (impact) {
let targetId = impact.targetId;
if (!targetId && impact.createNew) {
if (impact.targetType === 'asset') {
targetId = await insertAsset({
name: impact.createNew.name,
type: impact.createNew.type as any,
currentValue: 0,
currency: txn.currency,
note: impact.createNew.note || '',
});
} else {
targetId = await insertLiability({
name: impact.createNew.name,
type: impact.createNew.type as any,
outstandingAmount: 0,
currency: txn.currency,
interestRate: 0,
emiAmount: 0,
note: impact.createNew.note || '',
});
}
}
if (targetId) {
await applyTargetImpact(impact.targetType, targetId, impact.operation, txn.amount);
await saveTransactionImpact({
transactionId,
targetType: impact.targetType,
targetId,
operation: impact.operation,
});
}
}
// Reload current transactions and monthly stats
await get().loadTransactions({limit: 50});
await get().loadMonthlyStats();
},
editTransaction: async (id, txn, impact) => {
const oldTransaction = await getTransactionById(id);
const oldImpact = await getTransactionImpact(id);
if (oldTransaction && oldImpact) {
await reverseTargetImpact(oldImpact, oldTransaction.amount);
}
await updateTxnDb(id, txn);
const updatedTransaction = await getTransactionById(id);
if (updatedTransaction) {
if (impact) {
let targetId = impact.targetId;
if (!targetId && impact.createNew) {
if (impact.targetType === 'asset') {
targetId = await insertAsset({
name: impact.createNew.name,
type: impact.createNew.type as any,
currentValue: 0,
currency: updatedTransaction.currency,
note: impact.createNew.note || '',
});
} else {
targetId = await insertLiability({
name: impact.createNew.name,
type: impact.createNew.type as any,
outstandingAmount: 0,
currency: updatedTransaction.currency,
interestRate: 0,
emiAmount: 0,
note: impact.createNew.note || '',
});
}
}
if (targetId) {
await applyTargetImpact(impact.targetType, targetId, impact.operation, updatedTransaction.amount);
await saveTransactionImpact({
transactionId: id,
targetType: impact.targetType,
targetId,
operation: impact.operation,
});
}
} else if (oldImpact) {
await applyTargetImpact(oldImpact.targetType, oldImpact.targetId, oldImpact.operation, updatedTransaction.amount);
await saveTransactionImpact({
transactionId: id,
targetType: oldImpact.targetType,
targetId: oldImpact.targetId,
operation: oldImpact.operation,
});
}
}
await get().loadTransactions({limit: 50});
await get().loadMonthlyStats();
},
removeTransaction: async (id) => {
const transaction = await getTransactionById(id);
const impact = await getTransactionImpact(id);
if (transaction && impact) {
await reverseTargetImpact(impact, transaction.amount);
await deleteTransactionImpact(id);
}
await deleteTxnDb(id);
await get().loadTransactions({limit: 50});
await get().loadMonthlyStats();
},
loadMonthlyStats: async () => {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const [expense, income] = await Promise.all([
getMonthlyTotals('expense', year, month),
getMonthlyTotals('income', year, month),
]);
set({monthlyExpense: expense, monthlyIncome: income});
},
loadSpendingAnalytics: async () => {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate =
month === 12
? `${year + 1}-01-01`
: `${year}-${String(month + 1).padStart(2, '0')}-01`;
const [byCategory, trend] = await Promise.all([
getSpendingByCategory(startDate, endDate),
getMonthlySpendingTrend(6),
]);
set({spendingByCategory: byCategory, monthlyTrend: trend});
},
}));

4
src/store/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export {useSettingsStore} from './settingsStore';
export {useNetWorthStore} from './netWorthStore';
export {useExpenseStore} from './expenseStore';
export {mmkvStorage} from './mmkv';

72
src/store/mmkv.ts Normal file
View File

@@ -0,0 +1,72 @@
import {createMMKV, type MMKV} from 'react-native-mmkv';
import {AppSettings, Currency} from '../types';
let _storage: MMKV | null = null;
function getStorage(): MMKV {
if (!_storage) {
_storage = createMMKV({id: 'expensso-settings'});
}
return _storage;
}
// ─── Keys ────────────────────────────────────────────────────────────
const KEYS = {
BASE_CURRENCY: 'settings.baseCurrency',
LOCALE: 'settings.locale',
THEME: 'settings.theme',
BIOMETRIC: 'settings.biometricEnabled',
ONBOARDING: 'settings.onboardingComplete',
} as const;
// ─── Typed Getters / Setters ─────────────────────────────────────────
export const mmkvStorage = {
getBaseCurrency: (): Currency => {
return (getStorage().getString(KEYS.BASE_CURRENCY) as Currency) || 'INR';
},
setBaseCurrency: (currency: Currency) => {
getStorage().set(KEYS.BASE_CURRENCY, currency);
},
getLocale: (): string => {
return getStorage().getString(KEYS.LOCALE) || 'en';
},
setLocale: (locale: string) => {
getStorage().set(KEYS.LOCALE, locale);
},
getTheme: (): 'light' | 'dark' | 'system' => {
return (getStorage().getString(KEYS.THEME) as AppSettings['theme']) || 'light';
},
setTheme: (theme: AppSettings['theme']) => {
getStorage().set(KEYS.THEME, theme);
},
getBiometric: (): boolean => {
return getStorage().getBoolean(KEYS.BIOMETRIC) || false;
},
setBiometric: (enabled: boolean) => {
getStorage().set(KEYS.BIOMETRIC, enabled);
},
getOnboardingComplete: (): boolean => {
return getStorage().getBoolean(KEYS.ONBOARDING) || false;
},
setOnboardingComplete: (complete: boolean) => {
getStorage().set(KEYS.ONBOARDING, complete);
},
getAllSettings: (): AppSettings => ({
baseCurrency: mmkvStorage.getBaseCurrency(),
locale: mmkvStorage.getLocale(),
theme: mmkvStorage.getTheme(),
biometricEnabled: mmkvStorage.getBiometric(),
onboardingComplete: mmkvStorage.getOnboardingComplete(),
}),
clearAll: () => {
getStorage().clearAll();
},
};

101
src/store/netWorthStore.ts Normal file
View File

@@ -0,0 +1,101 @@
import {create} from 'zustand';
import {Asset, Liability, Currency} from '../types';
import {
getAssets,
getLiabilities,
insertAsset,
insertLiability,
updateAsset as updateAssetDb,
updateLiability as updateLiabilityDb,
deleteAsset as deleteAssetDb,
deleteLiability as deleteLiabilityDb,
getTotalAssets,
getTotalLiabilities,
saveNetWorthSnapshot,
} from '../db';
interface NetWorthState {
assets: Asset[];
liabilities: Liability[];
totalAssets: number;
totalLiabilities: number;
netWorth: number;
isLoading: boolean;
// Actions
loadNetWorth: () => Promise<void>;
addAsset: (asset: Omit<Asset, 'id' | 'createdAt' | 'lastUpdated'>) => Promise<void>;
editAsset: (id: string, asset: Partial<Omit<Asset, 'id' | 'createdAt'>>) => Promise<void>;
removeAsset: (id: string) => Promise<void>;
addLiability: (liability: Omit<Liability, 'id' | 'createdAt' | 'lastUpdated'>) => Promise<void>;
editLiability: (id: string, liability: Partial<Omit<Liability, 'id' | 'createdAt'>>) => Promise<void>;
removeLiability: (id: string) => Promise<void>;
takeSnapshot: (currency: Currency) => Promise<void>;
}
export const useNetWorthStore = create<NetWorthState>((set, get) => ({
assets: [],
liabilities: [],
totalAssets: 0,
totalLiabilities: 0,
netWorth: 0,
isLoading: false,
loadNetWorth: async () => {
set({isLoading: true});
try {
const [assets, liabilities, totalA, totalL] = await Promise.all([
getAssets(),
getLiabilities(),
getTotalAssets(),
getTotalLiabilities(),
]);
set({
assets,
liabilities,
totalAssets: totalA,
totalLiabilities: totalL,
netWorth: totalA - totalL,
isLoading: false,
});
} catch (error) {
console.error('Failed to load net worth:', error);
set({isLoading: false});
}
},
addAsset: async (asset) => {
await insertAsset(asset);
await get().loadNetWorth();
},
editAsset: async (id, asset) => {
await updateAssetDb(id, asset);
await get().loadNetWorth();
},
removeAsset: async (id) => {
await deleteAssetDb(id);
await get().loadNetWorth();
},
addLiability: async (liability) => {
await insertLiability(liability);
await get().loadNetWorth();
},
editLiability: async (id, liability) => {
await updateLiabilityDb(id, liability);
await get().loadNetWorth();
},
removeLiability: async (id) => {
await deleteLiabilityDb(id);
await get().loadNetWorth();
},
takeSnapshot: async (currency) => {
const {totalAssets, totalLiabilities} = get();
await saveNetWorthSnapshot(totalAssets, totalLiabilities, currency);
},
}));

View File

@@ -0,0 +1,53 @@
import {create} from 'zustand';
import {AppSettings, Currency} from '../types';
import {mmkvStorage} from './mmkv';
interface SettingsState extends AppSettings {
// Actions
setBaseCurrency: (currency: Currency) => void;
setLocale: (locale: string) => void;
setTheme: (theme: AppSettings['theme']) => void;
setBiometric: (enabled: boolean) => void;
setOnboardingComplete: (complete: boolean) => void;
hydrate: () => void;
}
export const useSettingsStore = create<SettingsState>((set) => ({
// Default state (hydrated from MMKV on app load)
baseCurrency: 'INR',
locale: 'en',
theme: 'light',
biometricEnabled: false,
onboardingComplete: false,
// Actions persist to MMKV and update zustand state simultaneously
setBaseCurrency: (currency: Currency) => {
mmkvStorage.setBaseCurrency(currency);
set({baseCurrency: currency});
},
setLocale: (locale: string) => {
mmkvStorage.setLocale(locale);
set({locale});
},
setTheme: (theme: AppSettings['theme']) => {
mmkvStorage.setTheme(theme);
set({theme});
},
setBiometric: (enabled: boolean) => {
mmkvStorage.setBiometric(enabled);
set({biometricEnabled: enabled});
},
setOnboardingComplete: (complete: boolean) => {
mmkvStorage.setOnboardingComplete(complete);
set({onboardingComplete: complete});
},
hydrate: () => {
const settings = mmkvStorage.getAllSettings();
set(settings);
},
}));

View File

@@ -0,0 +1,36 @@
/**
* MD3 ThemeProvider — React Context bridge between zustand settings and MD3 tokens.
*
* Wraps the app and exposes `useTheme()` which returns the fully resolved
* MD3Theme object (colors, typography, elevation, shape, spacing).
*/
import React, {createContext, useContext, useMemo} from 'react';
import {useColorScheme} from 'react-native';
import {useSettingsStore} from '../store/settingsStore';
import {LightTheme, DarkTheme} from './md3';
import type {MD3Theme} from './md3';
const ThemeContext = createContext<MD3Theme>(LightTheme);
export const ThemeProvider: React.FC<{children: React.ReactNode}> = ({children}) => {
const themeSetting = useSettingsStore(s => s.theme);
const systemScheme = useColorScheme();
const resolvedTheme = useMemo<MD3Theme>(() => {
if (themeSetting === 'dark') return DarkTheme;
if (themeSetting === 'light') return LightTheme;
return systemScheme === 'dark' ? DarkTheme : LightTheme;
}, [themeSetting, systemScheme]);
return (
<ThemeContext.Provider value={resolvedTheme}>
{children}
</ThemeContext.Provider>
);
};
/**
* Hook to access the full MD3 theme from anywhere in the tree.
*/
export const useTheme = (): MD3Theme => useContext(ThemeContext);

12
src/theme/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export {
MD3LightColors,
MD3DarkColors,
MD3Typography,
MD3Elevation,
MD3Shape,
Spacing,
LightTheme,
DarkTheme,
} from './md3';
export type {MD3Theme, MD3ColorScheme} from './md3';
export {ThemeProvider, useTheme} from './ThemeProvider';

380
src/theme/md3.ts Normal file
View File

@@ -0,0 +1,380 @@
/**
* Material Design 3 Theme System for Expensso
*
* Implements full MD3 color roles, typography scales, elevation,
* and shape tokens with "Material You"style palette.
*/
// ─── MD3 Color Palette ───────────────────────────────────────────────
export const MD3LightColors = {
// Primary
primary: '#6750A4',
onPrimary: '#FFFFFF',
primaryContainer: '#EADDFF',
onPrimaryContainer: '#21005D',
// Secondary
secondary: '#625B71',
onSecondary: '#FFFFFF',
secondaryContainer: '#E8DEF8',
onSecondaryContainer: '#1D192B',
// Tertiary (Fintech Teal)
tertiary: '#00897B',
onTertiary: '#FFFFFF',
tertiaryContainer: '#A7F3D0',
onTertiaryContainer: '#00382E',
// Error
error: '#B3261E',
onError: '#FFFFFF',
errorContainer: '#F9DEDC',
onErrorContainer: '#410E0B',
// Success (custom MD3 extension)
success: '#1B873B',
onSuccess: '#FFFFFF',
successContainer: '#D4EDDA',
onSuccessContainer: '#0A3D1B',
// Warning (custom MD3 extension)
warning: '#E65100',
onWarning: '#FFFFFF',
warningContainer: '#FFE0B2',
onWarningContainer: '#3E2723',
// Surface
background: '#FFFBFE',
onBackground: '#1C1B1F',
surface: '#FFFBFE',
onSurface: '#1C1B1F',
surfaceVariant: '#E7E0EC',
onSurfaceVariant: '#49454F',
surfaceDim: '#DED8E1',
surfaceBright: '#FFF8FF',
surfaceContainerLowest: '#FFFFFF',
surfaceContainerLow: '#F7F2FA',
surfaceContainer: '#F3EDF7',
surfaceContainerHigh: '#ECE6F0',
surfaceContainerHighest: '#E6E0E9',
// Outline
outline: '#79747E',
outlineVariant: '#CAC4D0',
// Inverse
inverseSurface: '#313033',
inverseOnSurface: '#F4EFF4',
inversePrimary: '#D0BCFF',
// Scrim & Shadow
scrim: '#000000',
shadow: '#000000',
// ─── App-Specific Semantic Colors ─────────────────────────────
income: '#1B873B',
expense: '#B3261E',
asset: '#6750A4',
liability: '#E65100',
// Chart palette (MD3 tonal)
chartColors: [
'#6750A4', '#00897B', '#1E88E5', '#E65100',
'#8E24AA', '#00ACC1', '#43A047', '#F4511E',
'#5C6BC0', '#FFB300',
],
};
export const MD3DarkColors: typeof MD3LightColors = {
// Primary
primary: '#D0BCFF',
onPrimary: '#381E72',
primaryContainer: '#4F378B',
onPrimaryContainer: '#EADDFF',
// Secondary
secondary: '#CCC2DC',
onSecondary: '#332D41',
secondaryContainer: '#4A4458',
onSecondaryContainer: '#E8DEF8',
// Tertiary
tertiary: '#4DB6AC',
onTertiary: '#003730',
tertiaryContainer: '#005048',
onTertiaryContainer: '#A7F3D0',
// Error
error: '#F2B8B5',
onError: '#601410',
errorContainer: '#8C1D18',
onErrorContainer: '#F9DEDC',
// Success
success: '#81C784',
onSuccess: '#0A3D1B',
successContainer: '#1B5E20',
onSuccessContainer: '#D4EDDA',
// Warning
warning: '#FFB74D',
onWarning: '#3E2723',
warningContainer: '#BF360C',
onWarningContainer: '#FFE0B2',
// Surface
background: '#141218',
onBackground: '#E6E0E9',
surface: '#141218',
onSurface: '#E6E0E9',
surfaceVariant: '#49454F',
onSurfaceVariant: '#CAC4D0',
surfaceDim: '#141218',
surfaceBright: '#3B383E',
surfaceContainerLowest: '#0F0D13',
surfaceContainerLow: '#1D1B20',
surfaceContainer: '#211F26',
surfaceContainerHigh: '#2B2930',
surfaceContainerHighest: '#36343B',
// Outline
outline: '#938F99',
outlineVariant: '#49454F',
// Inverse
inverseSurface: '#E6E0E9',
inverseOnSurface: '#313033',
inversePrimary: '#6750A4',
// Scrim & Shadow
scrim: '#000000',
shadow: '#000000',
// App-Specific
income: '#81C784',
expense: '#F2B8B5',
asset: '#D0BCFF',
liability: '#FFB74D',
chartColors: [
'#D0BCFF', '#4DB6AC', '#64B5F6', '#FFB74D',
'#CE93D8', '#4DD0E1', '#81C784', '#FF8A65',
'#9FA8DA', '#FFD54F',
],
};
// ─── MD3 Typography Scale ────────────────────────────────────────────
const fontFamily = 'System'; // Falls back to Roboto on Android, SF Pro on iOS
export const MD3Typography = {
displayLarge: {
fontFamily,
fontSize: 57,
fontWeight: '400' as const,
lineHeight: 64,
letterSpacing: -0.25,
},
displayMedium: {
fontFamily,
fontSize: 45,
fontWeight: '400' as const,
lineHeight: 52,
letterSpacing: 0,
},
displaySmall: {
fontFamily,
fontSize: 36,
fontWeight: '400' as const,
lineHeight: 44,
letterSpacing: 0,
},
headlineLarge: {
fontFamily,
fontSize: 32,
fontWeight: '400' as const,
lineHeight: 40,
letterSpacing: 0,
},
headlineMedium: {
fontFamily,
fontSize: 28,
fontWeight: '400' as const,
lineHeight: 36,
letterSpacing: 0,
},
headlineSmall: {
fontFamily,
fontSize: 24,
fontWeight: '400' as const,
lineHeight: 32,
letterSpacing: 0,
},
titleLarge: {
fontFamily,
fontSize: 22,
fontWeight: '500' as const,
lineHeight: 28,
letterSpacing: 0,
},
titleMedium: {
fontFamily,
fontSize: 16,
fontWeight: '500' as const,
lineHeight: 24,
letterSpacing: 0.15,
},
titleSmall: {
fontFamily,
fontSize: 14,
fontWeight: '500' as const,
lineHeight: 20,
letterSpacing: 0.1,
},
bodyLarge: {
fontFamily,
fontSize: 16,
fontWeight: '400' as const,
lineHeight: 24,
letterSpacing: 0.5,
},
bodyMedium: {
fontFamily,
fontSize: 14,
fontWeight: '400' as const,
lineHeight: 20,
letterSpacing: 0.25,
},
bodySmall: {
fontFamily,
fontSize: 12,
fontWeight: '400' as const,
lineHeight: 16,
letterSpacing: 0.4,
},
labelLarge: {
fontFamily,
fontSize: 14,
fontWeight: '500' as const,
lineHeight: 20,
letterSpacing: 0.1,
},
labelMedium: {
fontFamily,
fontSize: 12,
fontWeight: '500' as const,
lineHeight: 16,
letterSpacing: 0.5,
},
labelSmall: {
fontFamily,
fontSize: 11,
fontWeight: '500' as const,
lineHeight: 16,
letterSpacing: 0.5,
},
};
// ─── MD3 Elevation (Shadow Presets) ──────────────────────────────────
export const MD3Elevation = {
level0: {
shadowColor: 'transparent',
shadowOffset: {width: 0, height: 0},
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
level1: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 1},
shadowOpacity: 0.05,
shadowRadius: 3,
elevation: 1,
},
level2: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.08,
shadowRadius: 6,
elevation: 3,
},
level3: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 4},
shadowOpacity: 0.11,
shadowRadius: 8,
elevation: 6,
},
level4: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 6},
shadowOpacity: 0.14,
shadowRadius: 10,
elevation: 8,
},
level5: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 8},
shadowOpacity: 0.17,
shadowRadius: 12,
elevation: 12,
},
};
// ─── MD3 Shape (Corner Radii) ────────────────────────────────────────
export const MD3Shape = {
none: 0,
extraSmall: 4,
small: 8,
medium: 12,
large: 16,
extraLarge: 28,
full: 9999,
};
// ─── Spacing Scale ───────────────────────────────────────────────────
export const Spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 24,
xxxl: 32,
};
// ─── Composite Theme Object ─────────────────────────────────────────
export type MD3ColorScheme = typeof MD3LightColors;
export interface MD3Theme {
colors: MD3ColorScheme;
typography: typeof MD3Typography;
elevation: typeof MD3Elevation;
shape: typeof MD3Shape;
spacing: typeof Spacing;
isDark: boolean;
}
export const LightTheme: MD3Theme = {
colors: MD3LightColors,
typography: MD3Typography,
elevation: MD3Elevation,
shape: MD3Shape,
spacing: Spacing,
isDark: false,
};
export const DarkTheme: MD3Theme = {
colors: MD3DarkColors,
typography: MD3Typography,
elevation: MD3Elevation,
shape: MD3Shape,
spacing: Spacing,
isDark: true,
};

118
src/types/index.ts Normal file
View File

@@ -0,0 +1,118 @@
// ─── Core Domain Types ───────────────────────────────────────────────
export type Currency = 'INR' | 'USD' | 'EUR' | 'GBP';
export type PaymentMethod = 'UPI' | 'Cash' | 'Credit Card' | 'Debit Card' | 'Digital Wallet' | 'Net Banking' | 'Other';
export type AssetType = 'Bank' | 'Stocks' | 'Gold' | 'EPF' | 'Real Estate' | 'Mutual Funds' | 'Fixed Deposit' | 'PPF' | 'Other';
export type LiabilityType = 'Home Loan' | 'Car Loan' | 'Personal Loan' | 'Education Loan' | 'Credit Card' | 'Other';
export type TransactionType = 'income' | 'expense';
export type NetWorthTargetType = 'asset' | 'liability';
export type ImpactOperation = 'add' | 'subtract';
// ─── Database Models ─────────────────────────────────────────────────
export interface Category {
id: string;
name: string;
icon: string;
color: string;
type: TransactionType;
isDefault: boolean;
createdAt: string;
}
export interface Transaction {
id: string;
amount: number;
currency: Currency;
type: TransactionType;
categoryId: string;
categoryName?: string;
categoryIcon?: string;
categoryColor?: string;
paymentMethod: PaymentMethod;
note: string;
date: string; // ISO string
createdAt: string;
updatedAt: string;
}
export interface TransactionImpact {
transactionId: string;
targetType: NetWorthTargetType;
targetId: string;
operation: ImpactOperation;
}
export interface Asset {
id: string;
name: string;
type: AssetType;
currentValue: number;
currency: Currency;
note: string;
lastUpdated: string;
createdAt: string;
}
export interface Liability {
id: string;
name: string;
type: LiabilityType;
outstandingAmount: number;
currency: Currency;
interestRate: number;
emiAmount: number;
note: string;
lastUpdated: string;
createdAt: string;
}
// ─── Net Worth ───────────────────────────────────────────────────────
export interface NetWorthSnapshot {
id: string;
totalAssets: number;
totalLiabilities: number;
netWorth: number;
currency: Currency;
snapshotDate: string;
createdAt: string;
}
// ─── Settings ────────────────────────────────────────────────────────
export interface AppSettings {
baseCurrency: Currency;
locale: string;
theme: 'light' | 'dark' | 'system';
biometricEnabled: boolean;
onboardingComplete: boolean;
}
// ─── Chart Data ──────────────────────────────────────────────────────
export interface ChartDataPoint {
value: number;
label: string;
frontColor?: string;
}
export interface PieDataPoint {
value: number;
text: string;
color: string;
focused?: boolean;
}
// ─── Exchange Rates ──────────────────────────────────────────────────
export interface ExchangeRates {
INR: number;
USD: number;
EUR: number;
GBP: number;
}

122
src/utils/formatting.ts Normal file
View File

@@ -0,0 +1,122 @@
import {Currency} from '../types';
import {CURRENCY_SYMBOLS, STATIC_EXCHANGE_RATES} from '../constants';
/**
* Formats a number in Indian Lakh/Crore notation.
* e.g., 1500000 → "15,00,000"
*/
export function formatIndianNumber(num: number): string {
const isNegative = num < 0;
const absNum = Math.abs(num);
const [intPart, decPart] = absNum.toFixed(2).split('.');
if (intPart.length <= 3) {
const formatted = intPart + (decPart && decPart !== '00' ? '.' + decPart : '');
return isNegative ? '-' + formatted : formatted;
}
// Last 3 digits
const lastThree = intPart.slice(-3);
const remaining = intPart.slice(0, -3);
// Group remaining digits in pairs (Indian system)
const pairs = remaining.replace(/\B(?=(\d{2})+(?!\d))/g, ',');
const formatted =
pairs + ',' + lastThree + (decPart && decPart !== '00' ? '.' + decPart : '');
return isNegative ? '-' + formatted : formatted;
}
/**
* Formats a number in Western notation (comma every 3 digits).
*/
export function formatWesternNumber(num: number): string {
const isNegative = num < 0;
const absNum = Math.abs(num);
const [intPart, decPart] = absNum.toFixed(2).split('.');
const formatted =
intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') +
(decPart && decPart !== '00' ? '.' + decPart : '');
return isNegative ? '-' + formatted : formatted;
}
/**
* Formats amount with currency symbol and locale-aware grouping.
*/
export function formatCurrency(amount: number, currency: Currency = 'INR'): string {
const symbol = CURRENCY_SYMBOLS[currency] || '₹';
const formatted =
currency === 'INR'
? formatIndianNumber(amount)
: formatWesternNumber(amount);
return `${symbol}${formatted}`;
}
/**
* Compact formatting for large values on charts/cards.
* e.g., 1500000 → "₹15L", 25000000 → "₹2.5Cr"
*/
export function formatCompact(amount: number, currency: Currency = 'INR'): string {
const symbol = CURRENCY_SYMBOLS[currency] || '₹';
const abs = Math.abs(amount);
const sign = amount < 0 ? '-' : '';
if (currency === 'INR') {
if (abs >= 1_00_00_000) {
return `${sign}${symbol}${(abs / 1_00_00_000).toFixed(1).replace(/\.0$/, '')}Cr`;
}
if (abs >= 1_00_000) {
return `${sign}${symbol}${(abs / 1_00_000).toFixed(1).replace(/\.0$/, '')}L`;
}
if (abs >= 1_000) {
return `${sign}${symbol}${(abs / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
}
return `${sign}${symbol}${abs}`;
}
// Western compact
if (abs >= 1_000_000_000) {
return `${sign}${symbol}${(abs / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`;
}
if (abs >= 1_000_000) {
return `${sign}${symbol}${(abs / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
}
if (abs >= 1_000) {
return `${sign}${symbol}${(abs / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
}
return `${sign}${symbol}${abs}`;
}
/**
* Convert an amount from one currency to another using static rates
* (all rates are relative to INR as base).
*/
export function convertCurrency(
amount: number,
from: Currency,
to: Currency,
): number {
if (from === to) {return amount;}
// Convert to INR first, then to target
const amountInINR = amount * STATIC_EXCHANGE_RATES[from];
return amountInINR / STATIC_EXCHANGE_RATES[to];
}
/**
* Calculate percentage change between two values.
*/
export function percentageChange(current: number, previous: number): number {
if (previous === 0) {return current > 0 ? 100 : 0;}
return ((current - previous) / Math.abs(previous)) * 100;
}
/**
* Generate a simple UUID v4 string.
*/
export function generateId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

9
src/utils/index.ts Normal file
View File

@@ -0,0 +1,9 @@
export {
formatCurrency,
formatCompact,
formatIndianNumber,
formatWesternNumber,
convertCurrency,
percentageChange,
generateId,
} from './formatting';