Kantongku: aplikasi pencatat keuangan pribadi offline-first

This commit is contained in:
Bro
2026-08-16 12:22:36 +07:00
commit e0c4cef96b
57 changed files with 11641 additions and 0 deletions
+521
View File
@@ -0,0 +1,521 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useCallback, useMemo, useState } from 'react';
import { Alert, Image, Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
import Svg, { Circle, G } from 'react-native-svg';
import { colors } from '../theme/colors';
import { formatRupiah, formatRupiahShort } from '../utils/format';
import { getCategoryIcon3d } from '../utils/categoryIcons3d';
import { deleteTransaction, getExpenseByCategory, getMonthSummary, getTransactions } from '../db/repositories';
import type { TransactionType, TransactionWithCategory } from '../types';
import type { RootStackParamList } from '../navigation';
import { SwipeableRow } from '../components/SwipeableRow';
type Filter = 'all' | TransactionType;
const FILTERS: Array<{ key: Filter; label: string }> = [
{ key: 'all', label: 'Semua' },
{ key: 'income', label: 'Pemasukan' },
{ key: 'expense', label: 'Pengeluaran' },
];
export function HistoryScreen() {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [transactions, setTransactions] = useState<TransactionWithCategory[]>([]);
const [month, setMonth] = useState({ income: 0, expense: 0 });
const [filter, setFilter] = useState<Filter>('all');
const [categoryData, setCategoryData] = useState<
Array<{ categoryName: string; categoryIcon: string; total: number }>
>([]);
const load = useCallback(async () => {
const [txs, m, cats] = await Promise.all([
getTransactions(500),
getMonthSummary(),
getExpenseByCategory(),
]);
setTransactions(txs);
setMonth(m);
setCategoryData(cats);
}, []);
useFocusEffect(
useCallback(() => {
let active = true;
load().then(() => {
if (!active) return;
});
return () => {
active = false;
};
}, [load])
);
const sections = useMemo(() => {
const filtered =
filter === 'all' ? transactions : transactions.filter((t) => t.type === filter);
const map = new Map<string, TransactionWithCategory[]>();
for (const t of filtered) {
const key = new Date(t.occurredAt).toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
const arr = map.get(key) ?? [];
arr.push(t);
map.set(key, arr);
}
return [...map.entries()].map(([title, data]) => ({ title, data }));
}, [transactions, filter]);
const monthLabel = new Date().toLocaleDateString('id-ID', {
month: 'long',
year: 'numeric',
});
const net = month.income - month.expense;
const handleDelete = (item: TransactionWithCategory) => {
const name = item.note || item.categoryName || 'Transaksi';
Alert.alert(
'Hapus transaksi?',
`"${name}" (${item.type === 'income' ? '+' : '-'}${formatRupiah(item.amount)}) akan dihapus permanen.${
item.type === 'income' ? '\nSisihan otomatisnya juga ikut terhapus.' : ''
}`,
[
{ text: 'Batal', style: 'cancel' },
{
text: 'Hapus',
style: 'destructive',
onPress: async () => {
try {
await deleteTransaction(item.id);
await load();
} catch (e) {
Alert.alert('Gagal menghapus', String(e));
}
},
},
]
);
};
return (
<View style={styles.container}>
<SectionList
sections={sections}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
stickySectionHeadersEnabled={false}
ListHeaderComponent={
<>
{/* Ringkasan bulan */}
<LinearGradient
colors={[colors.primary, colors.primaryDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.summaryCard}
>
<View style={styles.ornCircleLarge} />
<View style={styles.ornCircleSmall} />
<Image
source={require('../../assets/cash-3d.png')}
style={styles.ornCoin}
resizeMode="contain"
/>
<View style={styles.summaryTop}>
<Text style={styles.summaryLabel}>RINGKASAN BULAN INI</Text>
<View style={styles.monthBadge}>
<Ionicons name="calendar" size={12} color={colors.primaryDark} />
<Text style={styles.monthBadgeText}>{monthLabel}</Text>
</View>
</View>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<View style={styles.summaryItemLabelRow}>
<Ionicons name="arrow-down-circle" size={13} color="#BBF7D0" />
<Text style={styles.summaryItemLabel}>Pemasukan</Text>
</View>
<Text style={[styles.summaryItemValue, { color: '#BBF7D0' }]}>
+{formatRupiah(month.income)}
</Text>
</View>
<View style={styles.summaryItem}>
<View style={styles.summaryItemLabelRow}>
<Ionicons name="arrow-up-circle" size={13} color="#FECACA" />
<Text style={styles.summaryItemLabel}>Pengeluaran</Text>
</View>
<Text style={[styles.summaryItemValue, { color: '#FECACA' }]}>
-{formatRupiah(month.expense)}
</Text>
</View>
<View style={styles.summaryItem}>
<View style={styles.summaryItemLabelRow}>
<Ionicons
name={net >= 0 ? 'trending-up' : 'trending-down'}
size={13}
color="#FFFFFF"
/>
<Text style={styles.summaryItemLabel}>Selisih</Text>
</View>
<Text style={[styles.summaryItemValue, { color: colors.white }]}>
{net >= 0 ? '+' : '-'}
{formatRupiah(Math.abs(net))}
</Text>
</View>
</View>
</LinearGradient>
{/* Donut pengeluaran per kategori */}
<View style={[styles.card, styles.donutCard]}>
<Text style={styles.donutTitle}>PENGELUARAN PER KATEGORI</Text>
{categoryData.length === 0 ? (
<Text style={styles.donutEmpty}>
Belum ada pengeluaran bulan ini.
</Text>
) : (
<View style={styles.donutRow}>
<DonutChart data={categoryData} />
<View style={styles.legend}>
{categoryData.slice(0, 5).map((c, i) => (
<View key={c.categoryName} style={styles.legendRow}>
<View
style={[
styles.legendDot,
{ backgroundColor: PALET[i % PALET.length] },
]}
/>
<Text style={styles.legendIcon}>{c.categoryIcon}</Text>
<Text style={styles.legendName} numberOfLines={1}>
{c.categoryName}
</Text>
<Text style={styles.legendPct}>
{Math.round((c.total / month.expense) * 100)}%
</Text>
</View>
))}
</View>
</View>
)}
</View>
{/* Filter */}
<View style={styles.filterRow}>
{FILTERS.map((f) => {
const active = f.key === filter;
return (
<Pressable
key={f.key}
style={[styles.filterChip, active && styles.filterChipActive]}
onPress={() => setFilter(f.key)}
>
<Text
style={[
styles.filterText,
active && { color: colors.white, fontWeight: '700' },
]}
>
{f.label}
</Text>
</Pressable>
);
})}
</View>
</>
}
renderSectionHeader={({ section }) => (
<Text style={styles.sectionHeader}>{section.title}</Text>
)}
ListEmptyComponent={
<View style={[styles.card, styles.emptyCard]}>
<Text style={styles.emptyEmoji}>🔍</Text>
<Text style={styles.emptyTitle}>Belum ada transaksi</Text>
<Text style={styles.emptyText}>
Transaksi yang kamu catat akan muncul di sini.
</Text>
</View>
}
renderItem={({ item }) => {
const icon3d = getCategoryIcon3d(item.categoryName);
return (
<SwipeableRow
onEdit={() =>
navigation.navigate('EditTransaksi', { transactionId: item.id })
}
onDelete={() => handleDelete(item)}
>
<View
style={[
styles.txRow,
{
backgroundColor:
item.type === 'income' ? colors.incomeSoft : colors.expenseSoft,
},
]}
>
<View style={styles.txIcon}>
{icon3d ? (
<Image source={icon3d} style={styles.txIcon3d} resizeMode="contain" />
) : (
<Text style={styles.txIconText}>{item.categoryIcon ?? '•'}</Text>
)}
</View>
<View style={styles.txInfo}>
<Text style={styles.txName} numberOfLines={1}>
{item.note || item.categoryName || 'Tanpa kategori'}
</Text>
<Text style={styles.txDate}>
{new Date(item.occurredAt).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</View>
<Text
style={[
styles.txAmount,
{ color: item.type === 'income' ? colors.income : colors.expense },
]}
>
{item.type === 'income' ? '+' : '-'}
{formatRupiah(item.amount)}
</Text>
</View>
</SwipeableRow>
);
}}
/>
</View>
);
}
const PALET = ['#0D9488', '#EF4444', '#F59E0B', '#3B82F6', '#8B5CF6', '#22C55E', '#EC4899', '#64748B'];
function DonutChart({
data,
}: {
data: Array<{ categoryName: string; total: number }>;
}) {
const size = 132;
const stroke = 20;
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const total = data.reduce((s, d) => s + d.total, 0);
let offset = 0;
return (
<View style={styles.donutWrap}>
<Svg width={size} height={size}>
<G rotation={-90} origin={`${size / 2}, ${size / 2}`}>
<Circle
cx={size / 2}
cy={size / 2}
r={r}
stroke="#E2E8F0"
strokeWidth={stroke}
fill="none"
/>
{data.map((d, i) => {
const frac = d.total / total;
const dash = frac * c;
const el = (
<Circle
key={d.categoryName}
cx={size / 2}
cy={size / 2}
r={r}
stroke={PALET[i % PALET.length]}
strokeWidth={stroke}
fill="none"
strokeDasharray={`${dash} ${c - dash}`}
strokeDashoffset={-offset}
/>
);
offset += dash;
return el;
})}
</G>
</Svg>
<View style={styles.donutCenter}>
<Text style={styles.donutCenterLabel}>TOTAL</Text>
<Text style={styles.donutCenterValue}>
{formatRupiahShort(total)}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
listContent: { paddingBottom: 32 },
card: {
backgroundColor: colors.surface,
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
},
// Summary card
summaryCard: {
marginHorizontal: 16,
marginTop: 12,
borderRadius: 20,
padding: 18,
overflow: 'hidden',
shadowColor: colors.primaryDark,
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
ornCircleLarge: {
position: 'absolute',
right: -40,
top: -50,
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: 'rgba(255,255,255,0.06)',
},
ornCircleSmall: {
position: 'absolute',
left: -20,
bottom: -30,
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(255,255,255,0.05)',
},
ornCoin: {
position: 'absolute',
left: -20,
top: 8,
width: 180,
height: 124,
opacity: 0.35,
transform: [{ perspective: 600 }, { rotateY: '20deg' }],
},
summaryTop: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
summaryLabel: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 1,
color: 'rgba(255,255,255,0.85)',
},
monthBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: 'rgba(255,255,255,0.92)',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
},
monthBadgeText: { fontSize: 11, fontWeight: '700', color: colors.primaryDark },
summaryRow: { flexDirection: 'row', gap: 12, marginTop: 14 },
summaryItem: { flex: 1 },
summaryItemLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 3 },
summaryItemLabel: { fontSize: 10, color: 'rgba(255,255,255,0.75)' },
summaryItemValue: { fontSize: 14, fontWeight: '800', marginTop: 4 },
// Filter
filterRow: {
flexDirection: 'row',
gap: 8,
marginHorizontal: 16,
marginTop: 14,
marginBottom: 4,
},
filterChip: {
flex: 1,
alignItems: 'center',
paddingVertical: 9,
borderRadius: 999,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
filterChipActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
filterText: { fontSize: 13, color: colors.textMuted },
// Donut
donutCard: { marginHorizontal: 16, marginTop: 14 },
donutTitle: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 1,
color: colors.textMuted,
marginBottom: 12,
},
donutEmpty: { fontSize: 13, color: colors.textMuted, textAlign: 'center', paddingVertical: 12 },
donutRow: { flexDirection: 'row', alignItems: 'center', gap: 16 },
donutWrap: { position: 'relative' },
donutCenter: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
},
donutCenterLabel: { fontSize: 9, fontWeight: '700', color: colors.textMuted },
donutCenterValue: { fontSize: 15, fontWeight: '800', color: colors.text },
legend: { flex: 1, gap: 7 },
legendRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
legendDot: { width: 10, height: 10, borderRadius: 5 },
legendIcon: { fontSize: 13 },
legendName: { flex: 1, fontSize: 12, color: colors.text, fontWeight: '600' },
legendPct: { fontSize: 12, fontWeight: '700', color: colors.textMuted },
// Section & items
sectionHeader: {
fontSize: 13,
fontWeight: '700',
color: colors.textMuted,
marginHorizontal: 20,
marginTop: 18,
marginBottom: 8,
},
txRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
paddingVertical: 12,
paddingHorizontal: 14,
shadowColor: '#0F172A',
shadowOpacity: 0.04,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
txIcon: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
txIconText: { fontSize: 18 },
txIcon3d: { width: 34, height: 34 },
txInfo: { flex: 1 },
txName: { fontSize: 14, fontWeight: '600', color: colors.text },
txDate: { fontSize: 11, color: colors.textMuted, marginTop: 2 },
txAmount: { fontSize: 14, fontWeight: '700' },
pressed: { opacity: 0.7 },
emptyCard: { alignItems: 'center', marginHorizontal: 16, marginTop: 24, paddingVertical: 28 },
emptyEmoji: { fontSize: 38, marginBottom: 8 },
emptyTitle: { fontSize: 15, fontWeight: '700', color: colors.text },
emptyText: { color: colors.textMuted, fontSize: 13, textAlign: 'center', marginTop: 4 },
});