554 lines
18 KiB
TypeScript
554 lines
18 KiB
TypeScript
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().catch(() => {});
|
|
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 =
|
|
target.targetAmount > 0
|
|
? Math.min(100, Math.round((total / target.targetAmount) * 100))
|
|
: 0;
|
|
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: 110 },
|
|
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 },
|
|
});
|