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
+552
View File
@@ -0,0 +1,552 @@
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, useState } from 'react';
import { Alert, Image, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
import { formatDateTime, formatRupiah, formatRupiahShort } from '../utils/format';
import {
deleteSavingsTarget,
getSavingsEvents,
getSavingsMonthComparison,
getSavingsStreak,
getSavingsTargets,
getSavingsTotal,
getSettingNumber,
getTransactionCount,
} from '../db/repositories';
import type { SavingsEvent, SavingsTarget } from '../types';
import type { RootStackParamList } from '../navigation';
interface BadgeDef {
icon: string;
title: string;
desc: string;
check: (s: { streak: number; savings: number; txCount: number }) => boolean;
}
const BADGES: BadgeDef[] = [
{ icon: '🔥', title: 'Konsisten', desc: 'Nabung 4 minggu berturut', check: (s) => s.streak >= 4 },
{ icon: '⚡', title: 'Mesin Nabung', desc: 'Nabung 8 minggu berturut', check: (s) => s.streak >= 8 },
{ icon: '🐷', title: 'Pemula', desc: 'Tabungan Rp 100 rb', check: (s) => s.savings >= 100000 },
{ icon: '💰', title: 'Aktif', desc: 'Tabungan Rp 500 rb', check: (s) => s.savings >= 500000 },
{ icon: '🤑', title: 'Jutawan Kecil', desc: 'Tabungan Rp 1 jt', check: (s) => s.savings >= 1000000 },
{ icon: '💎', title: 'Jago Sisih', desc: 'Tabungan Rp 5 jt', check: (s) => s.savings >= 5000000 },
{ icon: '📝', title: 'Rajin Catat', desc: '50 transaksi tercatat', check: (s) => s.txCount >= 50 },
{ icon: '✍️', title: 'Sultan Catat', desc: '100 transaksi tercatat', check: (s) => s.txCount >= 100 },
];
export function SavingsScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [total, setTotal] = useState(0);
const [streak, setStreak] = useState(0);
const [percent, setPercent] = useState(10);
const [events, setEvents] = useState<SavingsEvent[]>([]);
const [compare, setCompare] = useState({ current: 0, previous: 0 });
const [targets, setTargets] = useState<SavingsTarget[]>([]);
const [txCount, setTxCount] = useState(0);
const load = useCallback(async () => {
const [t, s, p, e, c, tg, tx] = await Promise.all([
getSavingsTotal(),
getSavingsStreak(),
getSettingNumber('auto_save_percent', 10),
getSavingsEvents(50),
getSavingsMonthComparison(),
getSavingsTargets(),
getTransactionCount(),
]);
setTotal(t);
setStreak(s);
setPercent(p);
setEvents(e);
setCompare(c);
setTargets(tg);
setTxCount(tx);
}, []);
useFocusEffect(
useCallback(() => {
let active = true;
load().then(() => {
if (!active) return;
});
return () => {
active = false;
};
}, [load])
);
const handleDeleteTarget = (target: SavingsTarget) => {
Alert.alert(
'Hapus target?',
`Target "${target.name}" akan dihapus. Tabunganmu tetap aman.`,
[
{ text: 'Batal', style: 'cancel' },
{
text: 'Hapus',
style: 'destructive',
onPress: async () => {
await deleteSavingsTarget(target.id);
setTargets(await getSavingsTargets());
},
},
]
);
};
const badgeStats = { streak, savings: total, txCount };
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Total tabungan */}
<LinearGradient
colors={[colors.primary, colors.primaryDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.totalCard}
>
<View style={styles.ornCircleLarge} />
<View style={styles.ornCircleSmall} />
<Image
source={require('../../assets/piggy-3d.png')}
style={styles.totalPiggy}
resizeMode="contain"
/>
<View style={styles.totalTop}>
<Text style={styles.totalLabel}>TOTAL TABUNGAN</Text>
<View style={styles.totalBadge}>
<Ionicons name="cash" size={14} color={colors.primaryDark} />
</View>
</View>
<Text style={styles.totalValue}>{formatRupiah(total)}</Text>
<Text style={styles.totalHint}>
✨ disisihkan otomatis {percent}% dari setiap pemasukan
</Text>
</LinearGradient>
{/* Streak */}
<LinearGradient
colors={['#FFFBEB', '#FEF3C7']}
style={[styles.card, styles.streakCard]}
>
<Ionicons
name="flame"
size={72}
color="rgba(217,119,6,0.12)"
style={styles.streakFlame}
/>
<View style={[styles.streakIconCircle, { backgroundColor: colors.white }]}>
<Ionicons name="flame" size={20} color="#D97706" />
</View>
<View style={styles.streakInfo}>
<Text style={[styles.streakValue, { color: '#92400E' }]}>
🔥 {streak} minggu berturut-turut
</Text>
<Text style={styles.streakHint}>
{streak === 0
? 'Catat pemasukan pertamamu untuk memulai streak!'
: 'Pertahankan! Jangan sampai putus minggu ini.'}
</Text>
</View>
</LinearGradient>
<ComparisonCard current={compare.current} previous={compare.previous} />
{/* Target Tabungan */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Target Tabungan</Text>
<Pressable onPress={() => navigation.navigate('TargetForm')} hitSlop={8}>
<View style={styles.addTargetBtn}>
<Ionicons name="add" size={16} color={colors.white} />
<Text style={styles.addTargetText}>Tambah</Text>
</View>
</Pressable>
</View>
{targets.length === 0 ? (
<View style={[styles.card, styles.emptyCard]}>
<Text style={styles.emptyEmoji}>🎯</Text>
<Text style={styles.emptyTitle}>Belum ada target</Text>
<Text style={styles.emptyText}>
Bikin target nyata (misal Dana Darurat 5 jt) — progress-nya bikin
nabung makin semangat!
</Text>
</View>
) : (
targets.map((target) => {
const pct = Math.min(100, Math.round((total / target.targetAmount) * 100));
const remaining = Math.max(0, target.targetAmount - total);
const now = new Date();
const dayOfMonth = now.getDate();
const perDay = dayOfMonth > 0 ? compare.current / dayOfMonth : 0;
const eta =
remaining > 0 && perDay > 0
? new Date(now.getTime() + Math.ceil(remaining / perDay) * 86400000)
: null;
return (
<View key={target.id} style={[styles.card, styles.targetCard]}>
<View style={styles.targetTop}>
<View style={styles.targetTitleRow}>
<Text style={styles.targetEmoji}>{target.icon}</Text>
<Text style={styles.targetName} numberOfLines={1}>
{target.name}
</Text>
</View>
<Pressable onPress={() => handleDeleteTarget(target)} hitSlop={8}>
<Ionicons name="trash-outline" size={17} color={colors.textMuted} />
</Pressable>
</View>
<View style={styles.targetTrack}>
<View
style={[styles.targetFill, { width: `${pct}%` }]}
/>
</View>
<View style={styles.targetMeta}>
<Text style={styles.targetAmount}>
{formatRupiahShort(total)} dari {formatRupiahShort(target.targetAmount)}
</Text>
<Text style={styles.targetPct}>{pct}%</Text>
</View>
<Text style={styles.targetEta}>
{remaining <= 0
? '🎉 Tercapai! Target kelar — bikin target baru!'
: eta
? `± selesai ${eta.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
})} dengan ritme saat ini`
: 'Catat pemasukan untuk mulai estimasi'}
</Text>
</View>
);
})
)}
{/* Pencapaian */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Pencapaian</Text>
</View>
<View style={styles.badgeGrid}>
{BADGES.map((b) => {
const unlocked = b.check(badgeStats);
return (
<View
key={b.title}
style={[
styles.badgeItem,
!unlocked && styles.badgeItemLocked,
]}
>
<Text style={[styles.badgeIcon, !unlocked && styles.badgeIconLocked]}>
{b.icon}
</Text>
<Text style={styles.badgeTitle}>{b.title}</Text>
<Text style={styles.badgeDesc}>{b.desc}</Text>
</View>
);
})}
</View>
<Text style={styles.sectionTitle}>Riwayat sisihan</Text>
{events.length === 0 ? (
<View style={[styles.card, styles.emptyCard]}>
<Text style={styles.emptyEmoji}>🐷</Text>
<Text style={styles.emptyTitle}>Belum ada sisihan</Text>
<Text style={styles.emptyText}>
Setiap pemasukan yang kamu catat akan otomatis menyisihkan {percent}% ke
sini. Mulai dari catat pemasukan pertamamu!
</Text>
</View>
) : (
events.map((e) => (
<View key={e.id} style={[styles.card, styles.eventRow]}>
<View style={styles.eventIcon}>
<Ionicons name="add" size={18} color={colors.primaryDark} />
</View>
<View style={styles.eventInfo}>
<Text style={styles.eventName} numberOfLines={1}>
{e.transactionNote || 'Pemasukan'}
</Text>
<Text style={styles.eventDate}>{formatDateTime(e.occurredAt)}</Text>
</View>
<Text style={styles.eventAmount}>+{formatRupiah(e.amount)}</Text>
</View>
))
)}
</ScrollView>
);
}
function ComparisonCard({ current, previous }: { current: number; previous: number }) {
const pct =
previous > 0 ? Math.round(((current - previous) / previous) * 100) : null;
let icon: React.ComponentProps<typeof Ionicons>['name'] = 'remove';
let title = '';
let sub = `Bulan ini: ${formatRupiahShort(current)} · Bulan lalu: ${formatRupiahShort(previous)}`;
let cardStyle = styles.compareNeutral;
let iconColor = colors.textMuted;
if (current === 0 && previous === 0) {
title = 'Belum ada sisihan bulan ini';
sub = 'Catat pemasukan untuk memulai! 🚀';
} else if (previous === 0) {
icon = 'sparkles';
iconColor = colors.primaryDark;
cardStyle = styles.compareUp;
title = 'Bulan ini mulai nabung — hebat! 🎉';
sub = `Tabungan bulan ini: ${formatRupiahShort(current)}`;
} else if (pct === null || pct === 0) {
icon = 'remove';
title = 'Stabil, sama seperti bulan lalu';
sub = `${sub} — Konsisten itu keren!`;
} else if (pct > 0) {
icon = 'trending-up';
iconColor = colors.income;
cardStyle = styles.compareUp;
title = `Naik ${pct}% dari bulan lalu — pertahankan! 🔥`;
} else {
icon = 'trending-down';
iconColor = '#D97706';
cardStyle = styles.compareDown;
title = `Turun ${Math.abs(pct)}% dari bulan lalu`;
sub = `${sub} — Santai, bulan ini masih panjang. Yang penting konsisten.`;
}
return (
<View style={[styles.card, styles.compareCard, cardStyle]}>
<View style={[styles.compareIconCircle, { backgroundColor: colors.white }]}>
<Ionicons name={icon} size={18} color={iconColor} />
</View>
<View style={styles.compareInfo}>
<Text style={styles.compareTitle}>{title}</Text>
<Text style={styles.compareSub}>{sub}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
content: { padding: 16, paddingBottom: 32 },
card: {
backgroundColor: colors.surface,
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#0F172A',
shadowOpacity: 0.04,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
// Total card
totalCard: {
marginBottom: 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: -50,
top: -60,
width: 180,
height: 180,
borderRadius: 90,
backgroundColor: 'rgba(255,255,255,0.06)',
},
ornCircleSmall: {
position: 'absolute',
left: -24,
bottom: -40,
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: 'rgba(255,255,255,0.05)',
},
totalPiggy: {
position: 'absolute',
right: -52,
bottom: -16,
width: 186,
height: 151,
opacity: 0.55,
transform: [{ perspective: 600 }, { rotateY: '-18deg' }, { scaleX: -1 }],
},
totalTop: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: 120,
},
totalLabel: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 1,
color: 'rgba(255,255,255,0.85)',
},
totalBadge: {
backgroundColor: 'rgba(255,255,255,0.9)',
borderRadius: 12,
padding: 5,
},
totalValue: {
fontSize: 32,
fontWeight: '800',
color: colors.white,
marginTop: 8,
paddingRight: 110,
},
totalHint: { fontSize: 12, color: 'rgba(255,255,255,0.85)', marginTop: 8 },
// Streak
streakCard: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
borderColor: '#FDE68A',
overflow: 'hidden',
},
streakFlame: {
position: 'absolute',
right: -6,
top: -10,
},
streakIconCircle: {
width: 38,
height: 38,
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
streakInfo: { flex: 1, paddingRight: 60 },
streakValue: { fontSize: 15, fontWeight: '700' },
streakHint: { fontSize: 12, color: colors.textMuted, marginTop: 2 },
// Comparison
compareCard: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 20,
},
compareUp: { backgroundColor: colors.incomeSoft, borderColor: colors.incomeSoft },
compareDown: { backgroundColor: '#FEF3C7', borderColor: '#FEF3C7' },
compareNeutral: {},
compareIconCircle: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
compareInfo: { flex: 1 },
compareTitle: { fontSize: 14, fontWeight: '700', color: colors.text },
compareSub: { fontSize: 12, color: colors.textMuted, marginTop: 2, lineHeight: 17 },
// Section
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 6,
marginBottom: 10,
},
sectionTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.text,
marginBottom: 10,
marginTop: 6,
},
addTargetBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: colors.primary,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
addTargetText: { color: colors.white, fontWeight: '700', fontSize: 12 },
// Target
targetCard: { marginBottom: 10 },
targetTop: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
targetTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 },
targetEmoji: { fontSize: 20 },
targetName: { fontSize: 15, fontWeight: '700', color: colors.text, flex: 1 },
targetTrack: {
height: 10,
borderRadius: 5,
backgroundColor: colors.background,
overflow: 'hidden',
},
targetFill: { height: '100%', borderRadius: 5, backgroundColor: colors.primary },
targetMeta: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 6,
},
targetAmount: { fontSize: 12, color: colors.textMuted },
targetPct: { fontSize: 12, fontWeight: '800', color: colors.primaryDark },
targetEta: { fontSize: 11, color: colors.textMuted, marginTop: 4, lineHeight: 15 },
// Badge
badgeGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 16 },
badgeItem: {
width: '31%',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#BBF7D0',
borderRadius: 14,
paddingVertical: 12,
paddingHorizontal: 6,
alignItems: 'center',
},
badgeItemLocked: { borderColor: colors.border, opacity: 0.45 },
badgeIcon: { fontSize: 26, marginBottom: 4 },
badgeIconLocked: { opacity: 0.5 },
badgeTitle: { fontSize: 12, fontWeight: '700', color: colors.text },
badgeDesc: { fontSize: 10, color: colors.textMuted, textAlign: 'center', marginTop: 2 },
// Riwayat
emptyCard: { alignItems: 'center', paddingVertical: 26, marginBottom: 12 },
emptyEmoji: { fontSize: 38, marginBottom: 8 },
emptyTitle: { fontSize: 15, fontWeight: '700', color: colors.text },
emptyText: {
color: colors.textMuted,
fontSize: 13,
textAlign: 'center',
marginTop: 4,
lineHeight: 19,
},
eventRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
marginBottom: 8,
},
eventIcon: {
width: 38,
height: 38,
borderRadius: 19,
backgroundColor: colors.primarySoft,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
eventInfo: { flex: 1 },
eventName: { fontSize: 14, fontWeight: '600', color: colors.text },
eventDate: { fontSize: 11, color: colors.textMuted, marginTop: 2 },
eventAmount: { fontSize: 15, fontWeight: '800', color: colors.primaryDark },
});