diff --git a/App.tsx b/App.tsx index b16b8cd..ba1a22d 100644 --- a/App.tsx +++ b/App.tsx @@ -4,13 +4,14 @@ import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { NavigationContainer } from '@react-navigation/native'; import { initDatabase } from './src/db/database'; -import { checkRecurringDue } from './src/db/repositories'; +import { checkRecurringDue, getSetting } from './src/db/repositories'; import { RootNavigator } from './src/navigation'; import { colors } from './src/theme/colors'; export default function App() { const [ready, setReady] = useState(false); const [error, setError] = useState(null); + const [initialRoute, setInitialRoute] = useState<'Main' | 'Onboarding'>('Onboarding'); useEffect(() => { initDatabase() @@ -21,6 +22,8 @@ export default function App() { } catch { // jangan sampai mengganggu startup } + const onboarded = await getSetting('onboarded'); + setInitialRoute(onboarded === '1' ? 'Main' : 'Onboarding'); setReady(true); }) .catch((e) => setError(String(e))); @@ -47,7 +50,7 @@ export default function App() { return ( - + diff --git a/src/db/repositories.ts b/src/db/repositories.ts index c7a387a..835e5c9 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -39,6 +39,36 @@ export async function setSetting(key: string, value: string): Promise { // ---------- Kategori ---------- +export async function addCategory(input: { + name: string; + type: TransactionType; + icon: string; +}): Promise { + const db = getDb(); + const result = await db.runAsync( + 'INSERT INTO categories (name, type, icon, is_default) VALUES (?, ?, ?, 0)', + [input.name, input.type, input.icon] + ); + return result.lastInsertRowId; +} + +export async function updateCategory( + id: number, + input: { name: string; icon: string } +): Promise { + const db = getDb(); + await db.runAsync('UPDATE categories SET name = ?, icon = ? WHERE id = ?', [ + input.name, + input.icon, + id, + ]); +} + +export async function deleteCategory(id: number): Promise { + const db = getDb(); + await db.runAsync('DELETE FROM categories WHERE id = ?', [id]); +} + export async function getCategories(type?: TransactionType): Promise { const db = getDb(); if (type) { diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 9f6ccbe..eb68061 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -9,6 +9,8 @@ import { AddTransactionScreen } from '../screens/AddTransactionScreen'; import { EditTransactionScreen } from '../screens/EditTransactionScreen'; import { RecurringFormScreen } from '../screens/RecurringFormScreen'; import { TargetFormScreen } from '../screens/TargetFormScreen'; +import { CategoryFormScreen } from '../screens/CategoryFormScreen'; +import { OnboardingScreen } from '../screens/OnboardingScreen'; import { SavingsScreen } from '../screens/SavingsScreen'; import { SettingsScreen } from '../screens/SettingsScreen'; import type { TransactionType } from '../types'; @@ -23,9 +25,18 @@ export type RootTabParamList = { export type RootStackParamList = { Main: undefined; + Onboarding: undefined; EditTransaksi: { transactionId: number }; RecurringForm: undefined; TargetForm: undefined; + CategoryForm: + | { + categoryId?: number; + initialName?: string; + initialIcon?: string; + defaultType?: TransactionType; + } + | undefined; }; const Tab = createBottomTabNavigator(); @@ -123,10 +134,18 @@ function MainTabs() { ); } -export function RootNavigator() { +export function RootNavigator({ + initialRoute = 'Main', +}: { + initialRoute?: 'Main' | 'Onboarding'; +}) { return ( - + + + ({ + presentation: 'modal', + headerShown: true, + title: route.params?.categoryId != null ? 'Edit Kategori' : 'Kategori Baru', + headerTitleStyle: { fontWeight: '700', color: colors.text }, + headerStyle: { backgroundColor: colors.background }, + headerShadowVisible: false, + })} + /> ); } diff --git a/src/screens/CategoryFormScreen.tsx b/src/screens/CategoryFormScreen.tsx new file mode 100644 index 0000000..5f33c2e --- /dev/null +++ b/src/screens/CategoryFormScreen.tsx @@ -0,0 +1,209 @@ +import { useNavigation, useRoute } from '@react-navigation/native'; +import type { RouteProp } from '@react-navigation/native'; +import { useState } from 'react'; +import { + Alert, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { colors } from '../theme/colors'; +import { addCategory, getCategories, updateCategory } from '../db/repositories'; +import type { TransactionType } from '../types'; +import type { RootStackParamList } from '../navigation'; + +const EMOJIS = [ + '๐Ÿœ', '๐ŸšŒ', '๐Ÿ›’', '๐Ÿงพ', '๐Ÿ’Š', '๐ŸŽฌ', '๐Ÿ’ผ', '๐ŸŽ', '๐Ÿ’ฐ', '๐Ÿ“ฆ', + '๐Ÿฑ', '๐Ÿถ', '๐Ÿ“ฑ', '๐Ÿ’ป', '๐ŸŽฎ', '๐Ÿ ', 'โšก', '๐Ÿ’ง', 'โ˜•', '๐Ÿ”', + '๐ŸŽ“', 'โœˆ๏ธ', '๐Ÿš—', 'โ›ฝ', '๐Ÿ‘•', '๐Ÿ’„', '๐ŸŽ‰', '๐Ÿ“š', '๐Ÿ‹๏ธ', '๐ŸŽต', + '๐Ÿท', '๐Ÿ’ณ', '๐Ÿฆ', '๐Ÿ“ˆ', '๐Ÿงง', '๐ŸŒพ', '๐Ÿ”ฅ', 'โญ', '๐ŸŽ', '๐Ÿงƒ', +]; + +export function CategoryFormScreen() { + const navigation = useNavigation(); + const route = useRoute>(); + const editing = route.params?.categoryId != null; + const [type, setType] = useState(route.params?.defaultType ?? 'expense'); + const [name, setName] = useState(route.params?.initialName ?? ''); + const [icon, setIcon] = useState(route.params?.initialIcon ?? '๐Ÿ“ฆ'); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + if (!name.trim()) { + Alert.alert('Nama kosong', 'Kasih nama kategorinya dulu, Mas.'); + return; + } + setSaving(true); + try { + if (editing && route.params?.categoryId != null) { + await updateCategory(route.params.categoryId, { name: name.trim(), icon }); + } else { + await addCategory({ name: name.trim(), type, icon }); + } + Alert.alert(editing ? 'Tersimpan โœ…' : 'Kategori dibuat โœ…', undefined, [ + { text: 'Oke', onPress: () => navigation.goBack() }, + ]); + } catch (e) { + Alert.alert('Gagal menyimpan', String(e)); + } finally { + setSaving(false); + } + }; + + return ( + + + {!editing && ( + <> + Tipe + + setType('income')} + > + + + Pemasukan + + + setType('expense')} + > + + - Pengeluaran + + + + + )} + + Nama kategori + + + Icon + + {EMOJIS.map((e) => ( + setIcon(e)} + > + {e} + + ))} + + + + + [styles.savePressable, pressed && styles.pressed]} + > + + + {saving ? 'Menyimpan...' : editing ? 'Simpan Perubahan' : 'Buat Kategori'} + + + + + + ); +} + +const styles = StyleSheet.create({ + flex: { flex: 1, backgroundColor: colors.background }, + container: { flex: 1 }, + content: { padding: 16, paddingBottom: 32 }, + sectionTitle: { + fontSize: 14, + fontWeight: '700', + color: colors.text, + marginBottom: 8, + marginTop: 8, + }, + toggleRow: { + flexDirection: 'row', + backgroundColor: colors.surface, + borderRadius: 14, + borderWidth: 1, + borderColor: colors.border, + padding: 4, + marginBottom: 8, + }, + toggleBtn: { flex: 1, alignItems: 'center', paddingVertical: 10, borderRadius: 10 }, + toggleIncome: { backgroundColor: colors.incomeSoft }, + toggleExpense: { backgroundColor: colors.expenseSoft }, + toggleText: { fontSize: 14, color: colors.textMuted }, + card: { + backgroundColor: colors.surface, + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: colors.border, + }, + input: { paddingVertical: 12, fontSize: 15, color: colors.text }, + emojiGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + emojiChip: { + width: 48, + height: 48, + borderRadius: 13, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + justifyContent: 'center', + }, + emojiChipActive: { + borderColor: colors.primary, + backgroundColor: colors.primarySoft, + }, + emojiText: { fontSize: 22 }, + saveBar: { + padding: 16, + backgroundColor: colors.background, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + savePressable: { + borderRadius: 14, + shadowColor: colors.primaryDark, + shadowOpacity: 0.25, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 4, + }, + saveButton: { + backgroundColor: colors.primary, + borderRadius: 14, + paddingVertical: 15, + alignItems: 'center', + }, + saveButtonText: { color: colors.white, fontWeight: '700', fontSize: 16 }, + pressed: { opacity: 0.85 }, +}); diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx new file mode 100644 index 0000000..68ab75f --- /dev/null +++ b/src/screens/OnboardingScreen.tsx @@ -0,0 +1,342 @@ +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useState } from 'react'; +import { + Alert, + Image, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { colors } from '../theme/colors'; +import { + addCategory, + getCategories, + setSetting, +} from '../db/repositories'; +import type { Category, TransactionType } from '../types'; +import type { RootStackParamList } from '../navigation'; + +const QUICK_EMOJIS = ['๐Ÿœ', '๐ŸšŒ', '๐Ÿ›’', '๐Ÿงพ', '๐Ÿ’Š', '๐ŸŽฌ', '๐Ÿ’ผ', '๐ŸŽ', '๐Ÿฑ', '๐Ÿ“ฑ', '๐Ÿ ', '๐ŸŽฎ']; + +export function OnboardingScreen() { + const navigation = + useNavigation>(); + const [type, setType] = useState('expense'); + const [name, setName] = useState(''); + const [icon, setIcon] = useState('๐Ÿœ'); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + + // Muat kategori yang sudah ada (default) + useState(() => { + getCategories().then((c) => { + setCategories(c); + setLoading(false); + }); + }); + + const handleAdd = async () => { + if (!name.trim()) { + Alert.alert('Nama kosong', 'Kasih nama kategorinya dulu ya.'); + return; + } + await addCategory({ name: name.trim(), type, icon }); + setName(''); + setCategories(await getCategories()); + }; + + const handleStart = async () => { + await setSetting('onboarded', '1'); + navigation.replace('Main'); + }; + + return ( + + + + + + + Selamat datang di Kantongku! ๐Ÿ‘› + + Catat pemasukan, sisihkan otomatis, dan jaga semangat nabungmu โ€” + semua offline, semua di perangkatmu. + + + + + Tambah kategori favoritmu (opsional) + + + Misal: Kucing ๐Ÿฑ, Gadget ๐Ÿ“ฑ โ€” biar pencatatanmu makin personal. + + + {/* Toggle tipe */} + + setType('income')} + > + + + Pemasukan + + + setType('expense')} + > + + - Pengeluaran + + + + + + + + {QUICK_EMOJIS.map((e) => ( + setIcon(e)} + > + {e} + + ))} + + + [styles.addBtn, pressed && styles.pressed]} + onPress={handleAdd} + > + + Tambah Kategori + + + {!loading && categories.length > 0 && ( + + Kategori yang tersedia: + + {categories.map((c) => ( + + + {c.icon} {c.name} + + + ))} + + + )} + + [styles.startBtn, pressed && styles.pressed]} + onPress={handleStart} + > + + ๐Ÿš€ Mulai Pakai Kantongku + + + + Kategori bisa diubah kapan saja di menu Pengaturan. + + + + ); +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: colors.background }, + content: { paddingBottom: 32 }, + hero: { + marginHorizontal: 16, + marginTop: 16, + borderRadius: 24, + padding: 22, + overflow: 'hidden', + alignItems: 'center', + shadowColor: colors.primaryDark, + shadowOpacity: 0.25, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 6, + }, + ornCircleLarge: { + position: 'absolute', + right: -40, + top: -50, + width: 160, + height: 160, + borderRadius: 80, + backgroundColor: 'rgba(255,255,255,0.07)', + }, + ornCircleSmall: { + position: 'absolute', + left: -30, + bottom: -40, + width: 110, + height: 110, + borderRadius: 55, + backgroundColor: 'rgba(255,255,255,0.06)', + }, + logo: { width: 96, height: 96, marginBottom: 12 }, + heroTitle: { + fontSize: 22, + fontWeight: '800', + color: colors.white, + textAlign: 'center', + }, + heroSub: { + fontSize: 13, + color: 'rgba(255,255,255,0.9)', + textAlign: 'center', + lineHeight: 19, + marginTop: 8, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.text, + marginTop: 20, + marginHorizontal: 16, + }, + sectionHint: { + fontSize: 12, + color: colors.textMuted, + marginHorizontal: 16, + marginTop: 4, + marginBottom: 10, + }, + toggleRow: { + flexDirection: 'row', + backgroundColor: colors.surface, + borderRadius: 14, + borderWidth: 1, + borderColor: colors.border, + padding: 4, + marginHorizontal: 16, + marginBottom: 10, + }, + toggleBtn: { flex: 1, alignItems: 'center', paddingVertical: 10, borderRadius: 10 }, + toggleIncome: { backgroundColor: colors.incomeSoft }, + toggleExpense: { backgroundColor: colors.expenseSoft }, + toggleText: { fontSize: 14, color: colors.textMuted }, + card: { + backgroundColor: colors.surface, + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: colors.border, + }, + input: { + marginHorizontal: 16, + paddingVertical: 12, + fontSize: 15, + color: colors.text, + marginBottom: 10, + }, + emojiGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginHorizontal: 16, + }, + emojiChip: { + width: 48, + height: 48, + borderRadius: 13, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + justifyContent: 'center', + }, + emojiChipActive: { + borderColor: colors.primary, + backgroundColor: colors.primarySoft, + }, + emojiText: { fontSize: 22 }, + addBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + backgroundColor: colors.primary, + borderRadius: 12, + paddingVertical: 12, + marginHorizontal: 16, + marginTop: 12, + }, + addBtnText: { color: colors.white, fontWeight: '700', fontSize: 14 }, + existingWrap: { marginHorizontal: 16, marginTop: 16 }, + existingLabel: { fontSize: 12, color: colors.textMuted, marginBottom: 8 }, + chipWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + chip: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 6, + }, + chipText: { fontSize: 12, color: colors.text }, + startBtn: { + marginHorizontal: 16, + marginTop: 24, + borderRadius: 14, + shadowColor: colors.primaryDark, + shadowOpacity: 0.25, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 4, + }, + startGradient: { + borderRadius: 14, + paddingVertical: 16, + alignItems: 'center', + }, + startText: { color: colors.white, fontWeight: '800', fontSize: 16 }, + footerNote: { + fontSize: 11, + color: colors.textMuted, + textAlign: 'center', + marginTop: 10, + }, + pressed: { opacity: 0.85 }, +}); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 52178c8..65480dc 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -18,12 +18,15 @@ import * as Sharing from 'expo-sharing'; import { colors } from '../theme/colors'; import { getDb } from '../db/database'; import { + deleteCategory, deleteRecurring, + getCategories, getRecurring, getSettingNumber, setSetting, } from '../db/repositories'; import { formatRupiah } from '../utils/format'; +import type { Category, TransactionType } from '../types'; import type { RootStackParamList } from '../navigation'; const PRESETS = [5, 10, 15, 20]; @@ -73,6 +76,7 @@ export function SettingsScreen() { dayOfMonth: number; }> >([]); + const [categories, setCategories] = useState([]); useFocusEffect( useCallback(() => { @@ -83,12 +87,33 @@ export function SettingsScreen() { getRecurring().then((r) => { if (active) setRecurring(r); }); + getCategories().then((c) => { + if (active) setCategories(c); + }); return () => { active = false; }; }, []) ); + const handleDeleteCategory = (category: Category) => { + Alert.alert( + 'Hapus kategori?', + `Kategori "${category.name}" akan dihapus. Transaksi lama tetap aman (jadi tanpa kategori).`, + [ + { text: 'Batal', style: 'cancel' }, + { + text: 'Hapus', + style: 'destructive', + onPress: async () => { + await deleteCategory(category.id); + setCategories(await getCategories()); + }, + }, + ] + ); + }; + const handleDeleteRecurring = (id: number, label: string) => { Alert.alert( 'Hapus transaksi berulang?', @@ -335,6 +360,55 @@ export function SettingsScreen() { + {/* Kategori */} + Kategori + + {(['income', 'expense'] as TransactionType[]).map((t) => ( + + + {t === 'income' ? '๐Ÿ’š Pemasukan' : 'โค๏ธ Pengeluaran'} + + {categories + .filter((c) => c.type === t) + .map((c) => ( + + + {c.icon} + + + navigation.navigate('CategoryForm', { + categoryId: c.id, + initialName: c.name, + initialIcon: c.icon, + }) + } + > + {c.name} + + handleDeleteCategory(c)} hitSlop={8}> + + + + ))} + + ))} + + [styles.dataRow, pressed && styles.pressed]} + onPress={() => navigation.navigate('CategoryForm', {})} + > + + + + + Tambah kategori + Kategori sendiri dengan icon bebas + + + + {/* Data */} Data @@ -513,6 +587,14 @@ const styles = StyleSheet.create({ customButtonText: { color: colors.primaryDark, fontWeight: '700', fontSize: 13 }, // Data dataCard: { marginBottom: 20 }, + catGroupLabel: { + fontSize: 12, + fontWeight: '700', + color: colors.textMuted, + marginTop: 6, + marginBottom: 4, + }, + catEmoji: { fontSize: 17 }, emptyRecurringText: { fontSize: 13, color: colors.textMuted,