mirror of
https://github.com/arkorty/Expensso.git
synced 2026-03-18 00:47:11 +00:00
init
This commit is contained in:
433
src/components/CustomBottomSheet.tsx
Normal file
433
src/components/CustomBottomSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/EmptyState.tsx
Normal file
43
src/components/EmptyState.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
54
src/components/SectionHeader.tsx
Normal file
54
src/components/SectionHeader.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
69
src/components/SummaryCard.tsx
Normal file
69
src/components/SummaryCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
106
src/components/TransactionItem.tsx
Normal file
106
src/components/TransactionItem.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
164
src/components/dashboard/AssetChipRow.tsx
Normal file
164
src/components/dashboard/AssetChipRow.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
277
src/components/dashboard/FinancialHealthGauges.tsx
Normal file
277
src/components/dashboard/FinancialHealthGauges.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
200
src/components/dashboard/NetWorthHeroCard.tsx
Normal file
200
src/components/dashboard/NetWorthHeroCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
223
src/components/dashboard/RecentActivityList.tsx
Normal file
223
src/components/dashboard/RecentActivityList.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
181
src/components/dashboard/WealthDistributionChart.tsx
Normal file
181
src/components/dashboard/WealthDistributionChart.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
5
src/components/dashboard/index.ts
Normal file
5
src/components/dashboard/index.ts
Normal 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
11
src/components/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user