feat: create Explore/StatsScreen

- Add monthly summary with useMemo
- Add category expenses analysis
- Display total balance overview
This commit is contained in:
Dita Aji Pratama 2026-04-18 12:22:56 +07:00
parent f0b2cf9f7e
commit add7ea82d3

View File

@ -1,112 +1,164 @@
import { Image } from 'expo-image'; import React, { useMemo } from 'react';
import { Platform, StyleSheet } from 'react-native'; import { View, Text, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
import { COLORS, FONTS } from '@/constants/theme';
import { Header } from '@/components/Header';
import { useTransactions } from '@/hooks/useTransactions';
import { calculateBalance, formatRupiah } from '@/utils/helpers';
import { Collapsible } from '@/components/ui/collapsible'; export default function ExploreScreen() {
import { ExternalLink } from '@/components/external-link'; const { transactions, loading } = useTransactions();
import ParallaxScrollView from '@/components/parallax-scroll-view';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Fonts } from '@/constants/theme';
export default function TabTwoScreen() { const monthlyData = useMemo(() => {
const grouped: Record<string, { income: number; expense: number }> = {};
transactions.forEach((t) => {
const monthKey = t.date.substring(0, 7);
if (!grouped[monthKey]) {
grouped[monthKey] = { income: 0, expense: 0 };
}
if (t.type === 'income') {
grouped[monthKey].income += t.amount;
} else {
grouped[monthKey].expense += t.amount;
}
});
return Object.entries(grouped)
.map(([month, data]) => ({
month,
income: data.income,
expense: data.expense,
balance: data.income - data.expense,
}))
.sort((a, b) => b.month.localeCompare(a.month))
.slice(0, 6);
}, [transactions]);
const categoryData = useMemo(() => {
const grouped: Record<string, number> = {};
transactions
.filter((t) => t.type === 'expense')
.forEach((t) => {
if (!grouped[t.category]) grouped[t.category] = 0;
grouped[t.category] += t.amount;
});
return Object.entries(grouped)
.map(([category, amount]) => ({ category, amount }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
}, [transactions]);
const balance = calculateBalance(transactions);
if (loading) {
return ( return (
<ParallaxScrollView <View style={styles.loading}>
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }} <ActivityIndicator size="large" color={COLORS.primary} />
headerImage={ </View>
<IconSymbol );
size={310} }
color="#808080"
name="chevron.left.forwardslash.chevron.right" const formatMonth = (monthStr: string) => {
style={styles.headerImage} const [year, month] = monthStr.split('-');
/> const date = new Date(parseInt(year), parseInt(month) - 1);
}> return new Intl.DateTimeFormat('id-ID', { month: 'long', year: 'numeric' }).format(date);
<ThemedView style={styles.titleContainer}> };
<ThemedText
type="title" return (
style={{ <View style={styles.container}>
fontFamily: Fonts.rounded, <Header title="Statistik Keuangan" />
}}> <ScrollView style={styles.content}>
Explore <View style={styles.card}>
</ThemedText> <Text style={styles.sectionTitle}>Ringkasan Keseluruhan</Text>
</ThemedView> <View style={styles.summaryRow}>
<ThemedText>This app includes example code to help you get started.</ThemedText> <View style={styles.summaryItem}>
<Collapsible title="File-based routing"> <Text style={styles.summaryLabel}>Total Pemasukan</Text>
<ThemedText> <Text style={[styles.summaryAmount, styles.income]}>
This app has two screens:{' '} {formatRupiah(balance.income)}
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '} </Text>
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText> </View>
</ThemedText> <View style={styles.summaryItem}>
<ThemedText> <Text style={styles.summaryLabel}>Total Pengeluaran</Text>
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '} <Text style={[styles.summaryAmount, styles.expense]}>
sets up the tab navigator. {formatRupiah(balance.expense)}
</ThemedText> </Text>
<ExternalLink href="https://docs.expo.dev/router/introduction"> </View>
<ThemedText type="link">Learn more</ThemedText> </View>
</ExternalLink> <View style={styles.divider} />
</Collapsible> <View style={styles.totalRow}>
<Collapsible title="Android, iOS, and web support"> <Text style={styles.totalLabel}>Saldo Akhir</Text>
<ThemedText> <Text style={styles.totalAmount}>
You can open this project on Android, iOS, and the web. To open the web version, press{' '} {formatRupiah(balance.total)}
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project. </Text>
</ThemedText> </View>
</Collapsible> </View>
<Collapsible title="Images">
<ThemedText> <View style={styles.card}>
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '} <Text style={styles.sectionTitle}>Ringkasan Bulanan</Text>
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for {monthlyData.length === 0 ? (
different screen densities <Text style={styles.emptyText}>Belum ada data</Text>
</ThemedText> ) : (
<Image monthlyData.map((item) => (
source={require('@/assets/images/react-logo.png')} <View key={item.month} style={styles.monthItem}>
style={{ width: 100, height: 100, alignSelf: 'center' }} <Text style={styles.monthLabel}>{formatMonth(item.month)}</Text>
/> <View style={styles.monthAmounts}>
<ExternalLink href="https://reactnative.dev/docs/images"> <Text style={styles.incomeSmall}>
<ThemedText type="link">Learn more</ThemedText> +{formatRupiah(item.income)}
</ExternalLink> </Text>
</Collapsible> <Text style={styles.expenseSmall}>
<Collapsible title="Light and dark mode components"> -{formatRupiah(item.expense)}
<ThemedText> </Text>
This template has light and dark mode support. The{' '} </View>
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect </View>
what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly. ))
</ThemedText> )}
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/"> </View>
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink> <View style={styles.card}>
</Collapsible> <Text style={styles.sectionTitle}>Kategori Pengeluaran</Text>
<Collapsible title="Animations"> {categoryData.length === 0 ? (
<ThemedText> <Text style={styles.emptyText}>Belum ada data</Text>
This template includes an example of an animated component. The{' '} ) : (
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses categoryData.map((item) => (
the powerful{' '} <View key={item.category} style={styles.categoryItem}>
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}> <Text style={styles.categoryLabel}>{item.category}</Text>
react-native-reanimated <Text style={styles.categoryAmount}>
</ThemedText>{' '} {formatRupiah(item.amount)}
library to create a waving hand animation. </Text>
</ThemedText> </View>
{Platform.select({ ))
ios: ( )}
<ThemedText> </View>
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '} </ScrollView>
component provides a parallax effect for the header image. </View>
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
headerImage: { container: { flex: 1, backgroundColor: COLORS.background },
color: '#808080', content: { flex: 1, padding: 16 },
bottom: -90, loading: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: COLORS.background },
left: -35, card: { backgroundColor: COLORS.card, borderRadius: 12, padding: 16, marginBottom: 16, elevation: 3 },
position: 'absolute', sectionTitle: { fontSize: FONTS.regular, fontWeight: 'bold', color: COLORS.text, marginBottom: 16 },
}, summaryRow: { flexDirection: 'row', justifyContent: 'space-between' },
titleContainer: { summaryItem: { flex: 1, alignItems: 'center' },
flexDirection: 'row', summaryLabel: { fontSize: FONTS.small, color: COLORS.textSecondary, marginBottom: 4 },
gap: 8, summaryAmount: { fontSize: FONTS.regular, fontWeight: '600' },
}, income: { color: COLORS.income },
expense: { color: COLORS.expense },
divider: { height: 1, backgroundColor: COLORS.border, marginVertical: 16 },
totalRow: { alignItems: 'center' },
totalLabel: { fontSize: FONTS.small, color: COLORS.textSecondary, marginBottom: 4 },
totalAmount: { fontSize: FONTS.large, fontWeight: 'bold', color: COLORS.text },
monthItem: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: COLORS.border },
monthLabel: { fontSize: FONTS.small, color: COLORS.text },
monthAmounts: { flexDirection: 'row', gap: 12 },
incomeSmall: { fontSize: FONTS.small, color: COLORS.income },
expenseSmall: { fontSize: FONTS.small, color: COLORS.expense },
categoryItem: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: COLORS.border },
categoryLabel: { fontSize: FONTS.small, color: COLORS.text },
categoryAmount: { fontSize: FONTS.small, fontWeight: '600', color: COLORS.expense },
emptyText: { fontSize: FONTS.small, color: COLORS.textSecondary, textAlign: 'center', paddingVertical: 16 },
}); });