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