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
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
+68
View File
@@ -0,0 +1,68 @@
import { StatusBar } from 'expo-status-bar';
import { useEffect, useState } from 'react';
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 { RootNavigator } from './src/navigation';
import { colors } from './src/theme/colors';
export default function App() {
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
initDatabase()
.then(async () => {
try {
// Buat transaksi berulang yang jatuh tempo hari ini
await checkRecurringDue();
} catch {
// jangan sampai mengganggu startup
}
setReady(true);
})
.catch((e) => setError(String(e)));
}, []);
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorTitle}>Terjadi kesalahan 😢</Text>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
if (!ready) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Menyiapkan Kantongku...</Text>
</View>
);
}
return (
<SafeAreaProvider>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
<StatusBar style="dark" />
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
padding: 24,
},
loadingText: { marginTop: 12, color: colors.textMuted, fontSize: 14 },
errorTitle: { fontSize: 16, fontWeight: '700', color: colors.text, marginBottom: 8 },
errorText: { color: colors.expense, fontSize: 13, textAlign: 'center' },
});
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+27
View File
@@ -0,0 +1,27 @@
{
"expo": {
"name": "Kantongku",
"slug": "kantongku",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"scheme": "kantongku",
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#CCFBF1",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-sqlite", "expo-sharing"]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+8
View File
@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+6557
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "sisih",
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.18.16",
"@react-navigation/native": "^7.3.16",
"@react-navigation/native-stack": "^7.18.8",
"expo": "~57.0.13",
"expo-document-picker": "~57.0.1",
"expo-file-system": "~57.0.4",
"expo-linear-gradient": "~57.0.1",
"expo-sharing": "~57.0.12",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-native": "0.86.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
}
+152
View File
@@ -0,0 +1,152 @@
import { Ionicons } from '@expo/vector-icons';
import { useRef, useState } from 'react';
import {
Animated,
Image,
PanResponder,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { colors } from '../theme/colors';
const ACTION_WIDTH = 72;
const ACTIONS_WIDTH = ACTION_WIDTH * 2;
export function SwipeableRow({
children,
onEdit,
onDelete,
onPress,
}: {
children: React.ReactNode;
onEdit: () => void;
onDelete: () => void;
onPress?: () => void;
}) {
const translateX = useRef(new Animated.Value(0)).current;
const [open, setOpen] = useState(false);
const settle = (toOpen: boolean) => {
setOpen(toOpen);
Animated.spring(translateX, {
toValue: toOpen ? -ACTIONS_WIDTH : 0,
useNativeDriver: true,
bounciness: 0,
speed: 20,
}).start();
};
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, g) =>
Math.abs(g.dx) > 8 && Math.abs(g.dx) > Math.abs(g.dy),
onPanResponderTerminationRequest: () => false,
onShouldBlockNativeResponder: () => true,
onPanResponderMove: (_, g) => {
const base = open ? -ACTIONS_WIDTH : 0;
const next = Math.max(-ACTIONS_WIDTH, Math.min(0, base + g.dx));
translateX.setValue(next);
},
onPanResponderRelease: (_, g) => {
const base = open ? -ACTIONS_WIDTH : 0;
settle(base + g.dx < -ACTIONS_WIDTH / 2);
},
onPanResponderTerminate: () => {
settle(open);
},
})
).current;
const handleRowPress = () => {
if (open) {
settle(false);
} else if (onPress) {
onPress();
}
};
return (
<View style={styles.container}>
{/* Aksi di belakang */}
<View style={styles.actions}>
<Pressable
style={({ pressed }) => [
styles.action,
styles.editAction,
pressed && styles.actionPressed,
]}
onPress={() => {
settle(false);
onEdit();
}}
>
<Image
source={require('../../assets/edit-3d.png')}
style={styles.editActionIcon}
resizeMode="contain"
/>
<Text style={styles.editActionText}>Edit</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.action,
styles.deleteAction,
pressed && styles.actionPressed,
]}
onPress={() => {
settle(false);
onDelete();
}}
>
<Image
source={require('../../assets/trash-3d.png')}
style={styles.deleteActionIcon}
resizeMode="contain"
/>
<Text style={styles.deleteActionText}>Hapus</Text>
</Pressable>
</View>
{/* Konten yang digeser */}
<Animated.View
style={{ transform: [{ translateX }] }}
{...panResponder.panHandlers}
>
<Pressable onPress={handleRowPress}>{children}</Pressable>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginHorizontal: 16,
marginBottom: 8,
borderRadius: 14,
overflow: 'hidden',
},
actions: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
flexDirection: 'row',
justifyContent: 'flex-end',
},
action: {
width: ACTION_WIDTH,
alignItems: 'center',
justifyContent: 'center',
gap: 4,
},
editAction: { backgroundColor: colors.primarySoft },
deleteAction: { backgroundColor: colors.expenseSoft },
actionPressed: { opacity: 0.85 },
editActionIcon: { width: 24, height: 48 },
deleteActionIcon: { width: 60, height: 34 },
editActionText: { color: colors.primaryDark, fontSize: 12, fontWeight: '700' },
deleteActionText: { color: '#B91C1C', fontSize: 12, fontWeight: '700' },
});
+100
View File
@@ -0,0 +1,100 @@
import * as SQLite from 'expo-sqlite';
let db: SQLite.SQLiteDatabase | null = null;
const SCHEMA = `
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '•',
is_default INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
amount INTEGER NOT NULL,
category_id INTEGER,
note TEXT NOT NULL DEFAULT '',
occurred_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (category_id) REFERENCES categories(id)
);
CREATE TABLE IF NOT EXISTS savings_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transaction_id INTEGER NOT NULL,
amount INTEGER NOT NULL,
occurred_at TEXT NOT NULL,
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS recurring (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
amount INTEGER NOT NULL,
category_id INTEGER,
note TEXT NOT NULL DEFAULT '',
day_of_month INTEGER NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS savings_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
target_amount INTEGER NOT NULL,
icon TEXT NOT NULL DEFAULT '🎯',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transactions_occurred ON transactions(occurred_at);
CREATE INDEX IF NOT EXISTS idx_savings_occurred ON savings_events(occurred_at);
`;
const DEFAULT_CATEGORIES: Array<{ name: string; type: 'income' | 'expense'; icon: string }> = [
{ name: 'Gaji', type: 'income', icon: '💼' },
{ name: 'Bonus / THR', type: 'income', icon: '🎁' },
{ name: 'Lainnya', type: 'income', icon: '💰' },
{ name: 'Makanan', type: 'expense', icon: '🍜' },
{ name: 'Transportasi', type: 'expense', icon: '🚌' },
{ name: 'Belanja', type: 'expense', icon: '🛒' },
{ name: 'Tagihan', type: 'expense', icon: '🧾' },
{ name: 'Kesehatan', type: 'expense', icon: '💊' },
{ name: 'Hiburan', type: 'expense', icon: '🎬' },
{ name: 'Lainnya', type: 'expense', icon: '📦' },
];
export async function initDatabase(): Promise<SQLite.SQLiteDatabase> {
if (db) return db;
db = await SQLite.openDatabaseAsync('sisih.db');
await db.execAsync(SCHEMA);
// Migrasi: kolom recurring_id di transactions (untuk transaksi berulang)
const txCols = await db.getAllAsync<{ name: string }>(
'PRAGMA table_info(transactions)'
);
if (!txCols.some((c) => c.name === 'recurring_id')) {
await db.execAsync('ALTER TABLE transactions ADD COLUMN recurring_id INTEGER');
}
const count = await db.getFirstAsync<{ n: number }>(
'SELECT COUNT(*) AS n FROM categories'
);
if (count?.n === 0) {
for (const c of DEFAULT_CATEGORIES) {
await db.runAsync(
'INSERT INTO categories (name, type, icon, is_default) VALUES (?, ?, ?, 1)',
[c.name, c.type, c.icon]
);
}
}
return db;
}
export function getDb(): SQLite.SQLiteDatabase {
if (!db) {
throw new Error('Database belum diinisialisasi. Panggil initDatabase() dulu.');
}
return db;
}
+437
View File
@@ -0,0 +1,437 @@
import { getDb } from './database';
import type {
AddTransactionInput,
Category,
Recurring,
RecurringInput,
SavingsEvent,
SavingsTarget,
TransactionType,
TransactionWithCategory,
} from '../types';
import { isoWeekKey, shiftWeekKey } from '../utils/format';
// ---------- Settings ----------
export async function getSetting(key: string): Promise<string | null> {
const db = getDb();
const row = await db.getFirstAsync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?',
[key]
);
return row?.value ?? null;
}
export async function getSettingNumber(key: string, def: number): Promise<number> {
const v = await getSetting(key);
const n = v === null ? NaN : Number(v);
return Number.isFinite(n) ? n : def;
}
export async function setSetting(key: string, value: string): Promise<void> {
const db = getDb();
await db.runAsync(
`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[key, value]
);
}
// ---------- Kategori ----------
export async function getCategories(type?: TransactionType): Promise<Category[]> {
const db = getDb();
if (type) {
return db.getAllAsync<Category>(
`SELECT id, name, type, icon, is_default AS isDefault
FROM categories WHERE type = ? ORDER BY is_default DESC, id ASC`,
[type]
);
}
return db.getAllAsync<Category>(
`SELECT id, name, type, icon, is_default AS isDefault
FROM categories ORDER BY type ASC, is_default DESC, id ASC`
);
}
// ---------- Transaksi ----------
export async function addTransaction(
input: AddTransactionInput,
recurringId?: number
): Promise<number> {
const db = getDb();
const occurredAt = (input.occurredAt ?? new Date()).toISOString();
const result = await db.runAsync(
`INSERT INTO transactions (type, amount, category_id, note, occurred_at, created_at, recurring_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
input.type,
input.amount,
input.categoryId ?? null,
input.note ?? '',
occurredAt,
new Date().toISOString(),
recurringId ?? null,
]
);
const transactionId = result.lastInsertRowId;
// Pay Yourself First: sisihkan otomatis dari pemasukan
if (input.type === 'income') {
const percent = await getSettingNumber('auto_save_percent', 10);
const saved = Math.round((input.amount * percent) / 100);
if (saved > 0) {
await db.runAsync(
'INSERT INTO savings_events (transaction_id, amount, occurred_at) VALUES (?, ?, ?)',
[transactionId, saved, occurredAt]
);
}
}
return transactionId;
}
export async function getTransactionById(
id: number
): Promise<TransactionWithCategory | null> {
const db = getDb();
return db.getFirstAsync<TransactionWithCategory>(
`SELECT t.id, t.type, t.amount, t.category_id AS categoryId, t.note,
t.occurred_at AS occurredAt, t.created_at AS createdAt,
c.name AS categoryName, c.icon AS categoryIcon
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
WHERE t.id = ?`,
[id]
);
}
export async function updateTransaction(
id: number,
input: AddTransactionInput
): Promise<void> {
const db = getDb();
const existing = await db.getFirstAsync<{ type: string; amount: number }>(
'SELECT type, amount FROM transactions WHERE id = ?',
[id]
);
if (!existing) throw new Error('Transaksi tidak ditemukan');
const occurredAt = (input.occurredAt ?? new Date()).toISOString();
await db.withTransactionAsync(async () => {
await db.runAsync(
`UPDATE transactions
SET type = ?, amount = ?, category_id = ?, note = ?, occurred_at = ?
WHERE id = ?`,
[
input.type,
input.amount,
input.categoryId ?? null,
input.note ?? '',
occurredAt,
id,
]
);
if (input.type === 'income') {
const percent = await getSettingNumber('auto_save_percent', 10);
const saved = Math.round((input.amount * percent) / 100);
if (existing.type === 'income') {
await db.runAsync(
'UPDATE savings_events SET amount = ? WHERE transaction_id = ?',
[saved, id]
);
} else {
await db.runAsync(
'INSERT INTO savings_events (transaction_id, amount, occurred_at) VALUES (?, ?, ?)',
[id, saved, occurredAt]
);
}
} else if (existing.type === 'income') {
await db.runAsync('DELETE FROM savings_events WHERE transaction_id = ?', [id]);
}
});
}
export async function getTransactions(limit = 50): Promise<TransactionWithCategory[]> {
const db = getDb();
return db.getAllAsync<TransactionWithCategory>(
`SELECT t.id, t.type, t.amount, t.category_id AS categoryId, t.note,
t.occurred_at AS occurredAt, t.created_at AS createdAt,
c.name AS categoryName, c.icon AS categoryIcon
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
ORDER BY t.occurred_at DESC, t.id DESC
LIMIT ?`,
[limit]
);
}
export async function deleteTransaction(id: number): Promise<void> {
const db = getDb();
await db.withTransactionAsync(async () => {
await db.runAsync('DELETE FROM savings_events WHERE transaction_id = ?', [id]);
await db.runAsync('DELETE FROM transactions WHERE id = ?', [id]);
});
}
export async function getBalance(): Promise<number> {
const db = getDb();
const row = await db.getFirstAsync<{ total: number | null }>(
`SELECT SUM(CASE WHEN type = 'income' THEN amount ELSE -amount END) AS total
FROM transactions`
);
return row?.total ?? 0;
}
export async function getTodaySummary(): Promise<{ income: number; expense: number }> {
const db = getDb();
const row = await db.getFirstAsync<{ income: number; expense: number }>(
`SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount END), 0) AS income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount END), 0) AS expense
FROM transactions
WHERE date(occurred_at) = date('now', 'localtime')`
);
return { income: row?.income ?? 0, expense: row?.expense ?? 0 };
}
export async function getTopExpenseCategory(
monthModifier: string | null = null
): Promise<{
categoryName: string;
categoryIcon: string;
total: number;
} | null> {
const db = getDb();
const modifierClause = monthModifier
? `strftime('%Y-%m', t.occurred_at, 'localtime', ?) = strftime('%Y-%m', 'now', 'localtime', ?)`
: `strftime('%Y-%m', t.occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')`;
const params = monthModifier ? [monthModifier, monthModifier] : [];
const row = await db.getFirstAsync<{
categoryName: string;
categoryIcon: string;
total: number;
}>(
`SELECT c.name AS categoryName, c.icon AS categoryIcon, SUM(t.amount) AS total
FROM transactions t
JOIN categories c ON c.id = t.category_id
WHERE t.type = 'expense' AND ${modifierClause}
GROUP BY c.id
ORDER BY total DESC
LIMIT 1`,
params
);
return row ?? null;
}
export async function getMonthSummary(): Promise<{ income: number; expense: number }> {
const db = getDb();
const row = await db.getFirstAsync<{ income: number; expense: number }>(
`SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount END), 0) AS income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount END), 0) AS expense
FROM transactions
WHERE strftime('%Y-%m', occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')`
);
return { income: row?.income ?? 0, expense: row?.expense ?? 0 };
}
// ---------- Transaksi Berulang ----------
export async function addRecurring(input: RecurringInput): Promise<number> {
const db = getDb();
const result = await db.runAsync(
`INSERT INTO recurring (type, amount, category_id, note, day_of_month, active, created_at)
VALUES (?, ?, ?, ?, ?, 1, ?)`,
[
input.type,
input.amount,
input.categoryId ?? null,
input.note ?? '',
input.dayOfMonth,
new Date().toISOString(),
]
);
return result.lastInsertRowId;
}
export async function getRecurring(): Promise<Array<Recurring & { categoryName: string | null }>> {
const db = getDb();
return db.getAllAsync(
`SELECT r.id, r.type, r.amount, r.category_id AS categoryId, r.note,
r.day_of_month AS dayOfMonth, r.active, r.created_at AS createdAt,
c.name AS categoryName
FROM recurring r
LEFT JOIN categories c ON c.id = r.category_id
ORDER BY r.day_of_month ASC, r.id DESC`
);
}
export async function deleteRecurring(id: number): Promise<void> {
const db = getDb();
await db.runAsync('DELETE FROM recurring WHERE id = ?', [id]);
}
/** Cek & buat otomatis transaksi berulang yang jatuh tempo hari ini (belum dibuat bulan ini). */
export async function checkRecurringDue(): Promise<number> {
const db = getDb();
const day = new Date().getDate();
const recs = await db.getAllAsync<{
id: number;
type: TransactionType;
amount: number;
category_id: number | null;
note: string;
}>(
'SELECT id, type, amount, category_id, note FROM recurring WHERE active = 1 AND day_of_month = ?',
[day]
);
let created = 0;
for (const r of recs) {
const existing = await db.getFirstAsync<{ n: number }>(
`SELECT COUNT(*) AS n FROM transactions
WHERE recurring_id = ?
AND strftime('%Y-%m', occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')`,
[r.id]
);
if ((existing?.n ?? 0) === 0) {
await addTransaction(
{
type: r.type,
amount: r.amount,
categoryId: r.category_id,
note: r.note,
occurredAt: new Date(),
},
r.id
);
created += 1;
}
}
return created;
}
// ---------- Target Tabungan ----------
export async function addSavingsTarget(input: {
name: string;
targetAmount: number;
icon: string;
}): Promise<number> {
const db = getDb();
const result = await db.runAsync(
'INSERT INTO savings_targets (name, target_amount, icon, created_at) VALUES (?, ?, ?, ?)',
[input.name, input.targetAmount, input.icon, new Date().toISOString()]
);
return result.lastInsertRowId;
}
export async function getSavingsTargets(): Promise<SavingsTarget[]> {
const db = getDb();
return db.getAllAsync<SavingsTarget>(
`SELECT id, name, target_amount AS targetAmount, icon, created_at AS createdAt
FROM savings_targets ORDER BY id ASC`
);
}
export async function deleteSavingsTarget(id: number): Promise<void> {
const db = getDb();
await db.runAsync('DELETE FROM savings_targets WHERE id = ?', [id]);
}
export async function getExpenseByCategory(): Promise<
Array<{ categoryName: string; categoryIcon: string; total: number }>
> {
const db = getDb();
return db.getAllAsync(
`SELECT c.name AS categoryName, c.icon AS categoryIcon, SUM(t.amount) AS total
FROM transactions t
JOIN categories c ON c.id = t.category_id
WHERE t.type = 'expense'
AND strftime('%Y-%m', t.occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')
GROUP BY c.id
ORDER BY total DESC`
);
}
export async function getTransactionCount(): Promise<number> {
const db = getDb();
const row = await db.getFirstAsync<{ n: number }>(
'SELECT COUNT(*) AS n FROM transactions'
);
return row?.n ?? 0;
}
// ---------- Tabungan ----------
export async function getSavingsTotal(): Promise<number> {
const db = getDb();
const row = await db.getFirstAsync<{ total: number | null }>(
'SELECT COALESCE(SUM(amount), 0) AS total FROM savings_events'
);
return row?.total ?? 0;
}
export async function getSavingsEvents(limit = 50): Promise<SavingsEvent[]> {
const db = getDb();
return db.getAllAsync<SavingsEvent>(
`SELECT se.id, se.transaction_id AS transactionId, se.amount,
se.occurred_at AS occurredAt, t.note AS transactionNote,
t.amount AS transactionAmount
FROM savings_events se
JOIN transactions t ON t.id = se.transaction_id
ORDER BY se.occurred_at DESC, se.id DESC
LIMIT ?`,
[limit]
);
}
export async function getSavingsMonthComparison(): Promise<{ current: number; previous: number }> {
const db = getDb();
const row = await db.getFirstAsync<{ current: number | null; previous: number | null }>(
`SELECT
COALESCE(SUM(CASE WHEN strftime('%Y-%m', occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime') THEN amount END), 0) AS current,
COALESCE(SUM(CASE WHEN strftime('%Y-%m', occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime', '-1 month') THEN amount END), 0) AS previous
FROM savings_events`
);
return { current: row?.current ?? 0, previous: row?.previous ?? 0 };
}
export async function getSavingsStreak(): Promise<number> {
const db = getDb();
const rows = await db.getAllAsync<{ occurredAt: string }>(
'SELECT occurred_at AS occurredAt FROM savings_events ORDER BY occurred_at ASC'
);
if (rows.length === 0) return 0;
const keys = [...new Set(rows.map((r) => isoWeekKey(new Date(r.occurredAt))))].sort();
const currentKey = isoWeekKey(new Date());
let expected = currentKey;
let streak = 0;
let started = false;
for (let i = keys.length - 1; i >= 0; i--) {
const k = keys[i];
if (!started) {
if (k === expected) {
started = true;
streak += 1;
expected = shiftWeekKey(expected, -1);
} else if (k === shiftWeekKey(expected, -1)) {
started = true;
streak += 1;
expected = shiftWeekKey(expected, -1);
} else {
break;
}
} else if (k === expected) {
streak += 1;
expected = shiftWeekKey(expected, -1);
} else {
break;
}
}
return streak;
}
+208
View File
@@ -0,0 +1,208 @@
import { Ionicons } from '@expo/vector-icons';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Pressable, StyleSheet, View } from 'react-native';
import { colors } from '../theme/colors';
import { DashboardScreen } from '../screens/DashboardScreen';
import { HistoryScreen } from '../screens/HistoryScreen';
import { AddTransactionScreen } from '../screens/AddTransactionScreen';
import { EditTransactionScreen } from '../screens/EditTransactionScreen';
import { RecurringFormScreen } from '../screens/RecurringFormScreen';
import { TargetFormScreen } from '../screens/TargetFormScreen';
import { SavingsScreen } from '../screens/SavingsScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import type { TransactionType } from '../types';
export type RootTabParamList = {
Beranda: undefined;
Riwayat: undefined;
Catat: { type?: TransactionType } | undefined;
Tabungan: undefined;
Pengaturan: undefined;
};
export type RootStackParamList = {
Main: undefined;
EditTransaksi: { transactionId: number };
RecurringForm: undefined;
TargetForm: undefined;
};
const Tab = createBottomTabNavigator<RootTabParamList>();
const Stack = createNativeStackNavigator<RootStackParamList>();
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
const tabIcon =
(focusedName: IoniconName, unfocusedName: IoniconName) =>
({ color, size, focused }: { color: string; size: number; focused: boolean }) =>
(
<Ionicons
name={focused ? focusedName : unfocusedName}
size={size}
color={color}
/>
);
/** Tombol "+" melingkar di tengah tab bar (FAB) */
function FabButton({
onPress,
accessibilityState,
}: {
onPress?: (...args: any[]) => void;
accessibilityState?: { selected?: boolean };
}) {
const focused = accessibilityState?.selected;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.fabWrapper, pressed && styles.fabPressed]}
hitSlop={8}
>
<View style={[styles.fab, focused && styles.fabActive]}>
<Ionicons name="add" size={32} color={colors.white} />
</View>
</Pressable>
);
}
function MainTabs() {
return (
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textMuted,
tabBarLabelStyle: styles.tabLabel,
tabBarStyle: styles.tabBar,
headerTitleStyle: { fontWeight: '700', color: colors.text },
headerStyle: { backgroundColor: colors.background },
headerShadowVisible: false,
}}
>
<Tab.Screen
name="Beranda"
component={DashboardScreen}
options={{
headerShown: false,
tabBarIcon: tabIcon('home', 'home-outline'),
}}
/>
<Tab.Screen
name="Riwayat"
component={HistoryScreen}
options={{
title: 'Riwayat',
tabBarIcon: tabIcon('list', 'list-outline'),
}}
/>
<Tab.Screen
name="Catat"
component={AddTransactionScreen}
options={{
title: 'Catat',
tabBarIcon: () => null,
tabBarLabel: () => null,
tabBarButton: (props) => <FabButton {...props} />,
}}
/>
<Tab.Screen
name="Tabungan"
component={SavingsScreen}
options={{
tabBarIcon: tabIcon('wallet', 'wallet-outline'),
}}
/>
<Tab.Screen
name="Pengaturan"
component={SettingsScreen}
options={{
tabBarIcon: tabIcon('settings', 'settings-outline'),
}}
/>
</Tab.Navigator>
);
}
export function RootNavigator() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Main" component={MainTabs} />
<Stack.Screen
name="EditTransaksi"
component={EditTransactionScreen}
options={{
presentation: 'modal',
headerShown: true,
title: 'Edit Transaksi',
headerTitleStyle: { fontWeight: '700', color: colors.text },
headerStyle: { backgroundColor: colors.background },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="RecurringForm"
component={RecurringFormScreen}
options={{
presentation: 'modal',
headerShown: true,
title: 'Transaksi Berulang',
headerTitleStyle: { fontWeight: '700', color: colors.text },
headerStyle: { backgroundColor: colors.background },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="TargetForm"
component={TargetFormScreen}
options={{
presentation: 'modal',
headerShown: true,
title: 'Target Tabungan',
headerTitleStyle: { fontWeight: '700', color: colors.text },
headerStyle: { backgroundColor: colors.background },
headerShadowVisible: false,
}}
/>
</Stack.Navigator>
);
}
const styles = StyleSheet.create({
tabBar: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
borderTopWidth: 1,
height: 62,
paddingTop: 4,
paddingBottom: 6,
},
tabLabel: {
fontSize: 10,
fontWeight: '600',
},
fabWrapper: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
top: -24,
},
fabPressed: { opacity: 0.85 },
fab: {
width: 58,
height: 58,
borderRadius: 29,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 4,
borderColor: colors.surface,
shadowColor: colors.primaryDark,
shadowOpacity: 0.35,
shadowRadius: 10,
shadowOffset: { width: 0, height: 5 },
elevation: 8,
},
fabActive: {
backgroundColor: colors.primaryDark,
},
});
+360
View File
@@ -0,0 +1,360 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useFocusEffect, useRoute } from '@react-navigation/native';
import type { RouteProp } from '@react-navigation/native';
import { useCallback, useEffect, useState } from 'react';
import {
Alert,
Image,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { colors } from '../theme/colors';
import { formatRibuan, formatRupiah } from '../utils/format';
import { getCategoryIcon3d } from '../utils/categoryIcons3d';
import { addTransaction, getCategories, getSettingNumber } from '../db/repositories';
import type { Category, TransactionType } from '../types';
import type { RootTabParamList } from '../navigation';
export function AddTransactionScreen() {
const route = useRoute<RouteProp<RootTabParamList, 'Catat'>>();
const [type, setType] = useState<TransactionType>(
route.params?.type ?? 'expense'
);
// Quick action dari Beranda: ganti tipe setiap kali params berubah
useEffect(() => {
const t = route.params?.type;
if (t) {
setType(t);
loadCategories(t);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [route.params?.type]);
const [amountText, setAmountText] = useState('');
const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [note, setNote] = useState('');
const [percent, setPercent] = useState(10);
const [saving, setSaving] = useState(false);
const loadCategories = useCallback(async (t: TransactionType) => {
const cats = await getCategories(t);
setCategories(cats);
setSelectedCategoryId(cats[0]?.id ?? null);
}, []);
useFocusEffect(
useCallback(() => {
let active = true;
(async () => {
const p = await getSettingNumber('auto_save_percent', 10);
if (active) setPercent(p);
})();
loadCategories(type);
return () => {
active = false;
};
}, [loadCategories, type])
);
const amount = Number(amountText.replace(/[^0-9]/g, '')) || 0;
const savedAmount = type === 'income' ? Math.round((amount * percent) / 100) : 0;
const switchType = (t: TransactionType) => {
setType(t);
setAmountText('');
loadCategories(t);
};
const handleSave = async () => {
if (amount <= 0) {
Alert.alert('Nominal kosong', 'Isi nominal dulu ya, Mas.');
return;
}
setSaving(true);
try {
await addTransaction({
type,
amount,
categoryId: selectedCategoryId,
note: note.trim(),
});
if (type === 'income') {
Alert.alert(
'Pemasukan tercatat ✅',
`${formatRupiah(amount)} masuk, dan ${formatRupiah(savedAmount)} otomatis disisihkan ke Tabungan (${percent}%).`
);
} else {
Alert.alert('Pengeluaran tercatat ✅', `${formatRupiah(amount)} dicatat sebagai pengeluaran.`);
}
setAmountText('');
setNote('');
setSelectedCategoryId(categories[0]?.id ?? null);
} catch (e) {
Alert.alert('Gagal menyimpan', String(e));
} finally {
setSaving(false);
}
};
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Toggle tipe */}
<View style={styles.toggleRow}>
<Pressable
style={[styles.toggleBtn, type === 'income' && styles.toggleIncome]}
onPress={() => switchType('income')}
>
<Text
style={[
styles.toggleText,
type === 'income' && { color: colors.income, fontWeight: '700' },
]}
>
+ Pemasukan
</Text>
</Pressable>
<Pressable
style={[styles.toggleBtn, type === 'expense' && styles.toggleExpense]}
onPress={() => switchType('expense')}
>
<Text
style={[
styles.toggleText,
type === 'expense' && { color: colors.expense, fontWeight: '700' },
]}
>
- Pengeluaran
</Text>
</Pressable>
</View>
{/* Nominal */}
<LinearGradient
colors={
type === 'income' ? ['#F0FDF4', '#DCFCE7'] : ['#FEF2F2', '#FEE2E2']
}
style={[
styles.card,
styles.amountCard,
type === 'income' ? styles.amountIncome : styles.amountExpense,
]}
>
<Image
source={require('../../assets/coin-3d.png')}
style={styles.amountCoin}
resizeMode="contain"
/>
<Text style={styles.cardLabel}>NOMINAL</Text>
<TextInput
style={styles.amountInput}
value={amountText ? `Rp ${formatRibuan(amountText)}` : ''}
onChangeText={(t) => setAmountText(t.replace(/[^0-9]/g, ''))}
placeholder="Rp 0"
placeholderTextColor={colors.border}
keyboardType="number-pad"
autoFocus
/>
</LinearGradient>
{/* Auto-sisih info */}
{type === 'income' && amount > 0 && (
<View style={[styles.card, styles.saveHintCard]}>
<Image
source={require('../../assets/coin-3d.png')}
style={styles.saveHintCoin}
resizeMode="contain"
/>
<Text style={styles.saveHintText}>
{formatRupiah(savedAmount)} otomatis masuk Tabungan ({percent}%)
</Text>
</View>
)}
{/* Kategori */}
<Text style={styles.sectionTitle}>Kategori</Text>
<View style={styles.categoryGrid}>
{categories.map((c) => {
const selected = c.id === selectedCategoryId;
const icon3d = getCategoryIcon3d(c.name);
return (
<Pressable
key={c.id}
style={[styles.categoryChip, selected && styles.categoryChipSelected]}
onPress={() => setSelectedCategoryId(c.id)}
>
{icon3d ? (
<Image source={icon3d} style={styles.categoryIcon3d} resizeMode="contain" />
) : (
<Text style={styles.categoryIcon}>{c.icon}</Text>
)}
<Text
style={[
styles.categoryName,
selected && { color: colors.primaryDark, fontWeight: '700' },
]}
>
{c.name}
</Text>
</Pressable>
);
})}
</View>
{/* Catatan */}
<Text style={styles.sectionTitle}>Catatan (opsional)</Text>
<TextInput
style={[styles.card, styles.noteInput]}
value={note}
onChangeText={setNote}
placeholder="Misal: makan siang di warteg"
placeholderTextColor={colors.textMuted}
/>
</ScrollView>
{/* Tombol Simpan fixed */}
<View style={styles.saveBar}>
<Pressable
onPress={handleSave}
disabled={amount <= 0 || saving}
style={({ pressed }) => [styles.savePressable, pressed && styles.pressed]}
>
<View style={styles.saveButton}>
<Text style={styles.saveButtonText}>
{saving ? 'Menyimpan...' : 'Simpan'}
</Text>
</View>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.background },
container: { flex: 1 },
content: { padding: 16, paddingBottom: 32 },
toggleRow: {
flexDirection: 'row',
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: colors.border,
padding: 4,
marginBottom: 12,
},
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,
},
amountCard: {
marginBottom: 12,
overflow: 'hidden',
},
amountIncome: { borderColor: '#BBF7D0' },
amountExpense: { borderColor: '#FECACA' },
amountCoin: {
position: 'absolute',
right: -14,
top: -4,
width: 114,
height: 115,
opacity: 0.3,
transform: [{ perspective: 600 }, { rotateY: '-20deg' }],
},
cardLabel: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 1,
color: colors.textMuted,
},
amountInput: {
fontSize: 34,
fontWeight: '800',
color: colors.text,
paddingVertical: 4,
},
amountPreview: { fontSize: 13, color: colors.textMuted },
saveHintCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.primarySoft,
borderColor: colors.primarySoft,
marginBottom: 12,
},
saveHintCoin: { width: 26, height: 26 },
saveHintText: { color: colors.primaryDark, fontWeight: '600', fontSize: 13, flex: 1 },
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.text,
marginBottom: 8,
marginTop: 8,
},
categoryGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
categoryChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 8,
},
categoryChipSelected: {
borderColor: colors.primary,
backgroundColor: colors.primarySoft,
},
categoryIcon: { fontSize: 16 },
categoryIcon3d: { width: 22, height: 22 },
categoryName: { fontSize: 13, color: colors.text },
noteInput: { paddingVertical: 10, fontSize: 14, color: colors.text },
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 },
});
+966
View File
@@ -0,0 +1,966 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Animated,
Image,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../theme/colors';
import { formatRupiah, formatRupiahShort, todayLabel } from '../utils/format';
import {
getBalance,
getMonthSummary,
getSavingsMonthComparison,
getSavingsStreak,
getSavingsTotal,
getTodaySummary,
getTopExpenseCategory,
getTransactions,
} from '../db/repositories';
import { getCategoryIcon3d } from '../utils/categoryIcons3d';
import type { TransactionWithCategory } from '../types';
import type { RootTabParamList } from '../navigation';
const QUOTES = [
'Setiap rupiah yang disisihkan adalah masa depanmu 🌱',
'Nabung itu bukan soal gaji besar, tapi soal konsisten 💪',
'Kamu gak perlu hebat untuk mulai, tapi harus mulai untuk hebat ✨',
'Uang yang disisihkan hari ini = tenang di hari esok 🌙',
'Kemenangan kecil tiap minggu = tabungan besar di akhir tahun 🏆',
];
export function DashboardScreen() {
const navigation = useNavigation<BottomTabNavigationProp<RootTabParamList>>();
const [balance, setBalance] = useState(0);
const [savings, setSavings] = useState(0);
const [streak, setStreak] = useState(0);
const [today, setToday] = useState({ income: 0, expense: 0 });
const [recent, setRecent] = useState<TransactionWithCategory[]>([]);
const [compare, setCompare] = useState({ current: 0, previous: 0 });
const [hidden, setHidden] = useState(true);
const heartScale = useRef(new Animated.Value(1)).current;
useEffect(() => {
const loop = Animated.loop(
Animated.sequence([
Animated.timing(heartScale, {
toValue: 1.3,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(heartScale, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
])
);
loop.start();
return () => loop.stop();
}, [heartScale]);
const [monthExpense, setMonthExpense] = useState(0);
const [monthIncome, setMonthIncome] = useState(0);
const [topCategory, setTopCategory] = useState<{
categoryName: string;
categoryIcon: string;
total: number;
} | null>(null);
const [topCategoryLast, setTopCategoryLast] = useState<{
categoryName: string;
categoryIcon: string;
total: number;
} | null>(null);
const load = useCallback(async () => {
const [b, s, st, t, r, c, m, top, topLast] = await Promise.all([
getBalance(),
getSavingsTotal(),
getSavingsStreak(),
getTodaySummary(),
getTransactions(5),
getSavingsMonthComparison(),
getMonthSummary(),
getTopExpenseCategory(),
getTopExpenseCategory('-1 month'),
]);
setBalance(b);
setSavings(s);
setStreak(st);
setToday(t);
setRecent(r);
setCompare(c);
setMonthExpense(m.expense);
setMonthIncome(m.income);
setTopCategory(top);
setTopCategoryLast(topLast);
}, []);
useFocusEffect(
useCallback(() => {
let active = true;
load().then(() => {
if (!active) return;
});
return () => {
active = false;
};
}, [load])
);
const quote = QUOTES[new Date().getDate() % QUOTES.length];
const pct =
compare.previous > 0
? Math.round(((compare.current - compare.previous) / compare.previous) * 100)
: null;
let growthLabel = '';
let growthColor = '#166534';
let growthIcon: React.ComponentProps<typeof Ionicons>['name'] = 'remove';
if (compare.previous === 0 && compare.current > 0) {
growthLabel = 'Mulai nabung bulan ini 🎉';
} else if (pct !== null && pct > 0) {
growthLabel = `↑ ${pct}% dari bulan lalu`;
growthIcon = 'arrow-up';
} else if (pct !== null && pct < 0) {
growthLabel = `↓ ${Math.abs(pct)}% dari bulan lalu`;
growthColor = '#B45309';
growthIcon = 'arrow-down';
} else if (pct === 0) {
growthLabel = 'Stabil dari bulan lalu';
growthColor = '#0F766E';
}
const showGrowth = compare.current > 0 || compare.previous > 0;
// Proyeksi tabungan akhir bulan (berdasarkan ritme sisih saat ini)
const now = new Date();
const dayOfMonth = now.getDate();
const totalDays = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
const remainingDays = totalDays - dayOfMonth;
const perDay = dayOfMonth > 0 ? compare.current / dayOfMonth : 0;
const projection = Math.round(compare.current + perDay * remainingDays);
const topIcon3d = topCategory ? getCategoryIcon3d(topCategory.categoryName) : null;
// Saldo cukup berapa hari (rata-rata pengeluaran harian bulan ini)
const avgDailyExpense = dayOfMonth > 0 ? monthExpense / dayOfMonth : 0;
const coverageDays =
avgDailyExpense > 0 ? Math.floor(balance / avgDailyExpense) : null;
const coverageOk = coverageDays !== null && coverageDays >= remainingDays;
// Tip otomatis dari pola kategori
let tip: string | null = null;
if (topCategory && topCategoryLast) {
if (topCategoryLast.categoryName === topCategory.categoryName) {
const change =
topCategoryLast.total > 0
? Math.round(((topCategory.total - topCategoryLast.total) / topCategoryLast.total) * 100)
: null;
if (change !== null && Math.abs(change) >= 5) {
tip =
change > 0
? `Pengeluaran ${topCategory.categoryName} naik ${change}% dari bulan lalu — cek dulu yuk 👀`
: `Pengeluaran ${topCategory.categoryName} turun ${Math.abs(change)}% dari bulan lalu — keren! 🎉`;
}
} else {
tip = `Kategori paling boros bergeser: ${topCategory.categoryName} (bulan lalu ${topCategoryLast.categoryName})`;
}
}
const hasTodayActivity = today.income > 0 || today.expense > 0;
const monthNet = monthIncome - monthExpense;
// Nyawa keuangan (health bar)
const hpPct =
avgDailyExpense > 0
? Math.min(100, Math.round(((coverageDays ?? 0) / remainingDays) * 100))
: 100;
let hpStatus = 'SEHAT';
let hpColor = '#16A34A';
let hpBarColor = '#22C55E';
let hpMsg =
avgDailyExpense > 0
? `Nyawa aman — saldo cukup ${coverageDays} hari (sisa ${remainingDays}) 🛡️`
: 'Belum ada pengeluaran bulan ini — nyawa penuh! 😎';
if (avgDailyExpense > 0 && hpPct < 40) {
hpStatus = 'KRITIS';
hpColor = '#DC2626';
hpBarColor = '#EF4444';
hpMsg = `Saldo cukup ${coverageDays} dari ${remainingDays} hari — kurangi belanja! 🚨`;
} else if (avgDailyExpense > 0 && hpPct < 75) {
hpStatus = 'WASPADA';
hpColor = '#D97706';
hpBarColor = '#F59E0B';
hpMsg = `Saldo cukup ${coverageDays} dari ${remainingDays} hari — jaga pengeluaran ⚔️`;
}
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<LinearGradient
colors={['#FFFFFF', '#FFFFFF', colors.background]}
style={styles.header}
>
<View style={styles.headerRow}>
<Image
source={require('../../assets/wallet-3d-teal-logo.png')}
style={styles.logo}
resizeMode="contain"
/>
<View style={styles.headerText}>
<Text style={styles.greeting}>Halo, Mas 👋</Text>
<Text style={styles.date}>{todayLabel()}</Text>
</View>
<View style={styles.avatarCircle}>
<Text style={styles.avatarText}>N</Text>
</View>
</View>
<View style={styles.quoteBubble}>
<Ionicons name="leaf" size={14} color={colors.primaryDark} />
<Text style={styles.quoteText}>{quote}</Text>
</View>
</LinearGradient>
{/* Kartu saldo */}
<View style={styles.balanceWrapper}>
<LinearGradient
colors={[colors.primary, colors.primaryDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.balanceCard}
>
{/* Ornamen dekoratif */}
<View style={styles.ornamentCircleLarge} />
<View style={styles.ornamentCircleMid} />
<View style={styles.ornamentCircleSmall} />
<Image
source={require('../../assets/wallet-3d-logo.png')}
style={styles.ornamentIcon}
resizeMode="contain"
/>
<View style={styles.balanceTop}>
<Text style={styles.cardLabelLight}>SALDO SAAT INI</Text>
<Pressable
onPress={() => setHidden((h) => !h)}
hitSlop={12}
style={styles.eyeButton}
>
<Ionicons
name={hidden ? 'eye-off' : 'eye'}
size={18}
color="rgba(255,255,255,0.9)"
/>
</Pressable>
</View>
{hidden ? (
<View style={styles.balanceHiddenRow}>
<Text style={styles.hiddenRp}>Rp</Text>
<Image
source={require('../../assets/star-3d.png')}
style={styles.starMask}
resizeMode="contain"
/>
<Image
source={require('../../assets/star-3d.png')}
style={styles.starMask}
resizeMode="contain"
/>
<Image
source={require('../../assets/star-3d.png')}
style={styles.starMask}
resizeMode="contain"
/>
<Image
source={require('../../assets/star-3d.png')}
style={styles.starMask}
resizeMode="contain"
/>
<Image
source={require('../../assets/star-3d.png')}
style={styles.starMask}
resizeMode="contain"
/>
<Image
source={require('../../assets/star-3d.png')}
style={styles.starMask}
resizeMode="contain"
/>
</View>
) : (
<Text style={styles.balanceValue}>{formatRupiah(balance)}</Text>
)}
{showGrowth && (
<View style={styles.growthBadge}>
<Ionicons name={growthIcon} size={13} color={growthColor} />
<Text style={[styles.growthText, { color: growthColor }]}>
{growthLabel}
</Text>
</View>
)}
<View style={styles.divider} />
<View style={styles.todayRow}>
<View style={styles.todayItem}>
<View style={styles.todayLabelRow}>
<Ionicons name="arrow-down-circle" size={13} color="#BBF7D0" />
<Text style={styles.todayLabelLight}>Pemasukan hari ini</Text>
</View>
<Text style={[styles.todayValue, { color: '#BBF7D0' }]}>
+{formatRupiahShort(today.income)}
</Text>
</View>
<View style={styles.todayItem}>
<View style={styles.todayLabelRow}>
<Ionicons name="arrow-up-circle" size={13} color="#FECACA" />
<Text style={styles.todayLabelLight}>Pengeluaran hari ini</Text>
</View>
<Text style={[styles.todayValue, { color: '#FECACA' }]}>
-{formatRupiahShort(today.expense)}
</Text>
</View>
</View>
</LinearGradient>
</View>
{/* Nyawa keuangan */}
<View style={[styles.card, styles.hpCard]}>
<View style={styles.hpHeader}>
<View style={styles.hpTitleRow}>
<Animated.Text
style={{ fontSize: 15, transform: [{ scale: heartScale }] }}
>
❤️
</Animated.Text>
<Text style={styles.hpTitle}>Uangmu, Nyawamu!</Text>
</View>
<Text style={[styles.hpPct, { color: hpColor }]}>{hpPct}%</Text>
</View>
<View style={styles.hpTrack}>
<View
style={[
styles.hpFill,
{ width: `${hpPct}%`, backgroundColor: hpBarColor },
]}
/>
</View>
<View style={styles.hpFooter}>
<Text style={[styles.hpStatus, { color: hpColor }]}>{hpStatus}</Text>
<Text style={styles.hpMsg}>{hpMsg}</Text>
</View>
</View>
{/* Quick actions */}
<View style={styles.quickRow}>
<Pressable
style={styles.quickItem}
onPress={() => navigation.navigate('Catat', { type: 'income' })}
>
<View style={[styles.quickIconCircle, { backgroundColor: '#DCFCE7' }]}>
<Image
source={require('../../assets/income-3d.png')}
style={styles.quickIcon}
resizeMode="contain"
/>
</View>
<Text style={styles.quickLabel}>Pemasukan</Text>
</Pressable>
<Pressable
style={styles.quickItem}
onPress={() => navigation.navigate('Catat', { type: 'expense' })}
>
<View style={[styles.quickIconCircle, { backgroundColor: '#FEE2E2' }]}>
<Image
source={require('../../assets/expense-3d.png')}
style={styles.quickIcon}
resizeMode="contain"
/>
</View>
<Text style={styles.quickLabel}>Pengeluaran</Text>
</Pressable>
<Pressable
style={styles.quickItem}
onPress={() => navigation.navigate('Tabungan')}
>
<View style={[styles.quickIconCircle, { backgroundColor: '#CCFBF1' }]}>
<Image
source={require('../../assets/piggy-3d.png')}
style={styles.quickIcon}
resizeMode="contain"
/>
</View>
<Text style={styles.quickLabel}>Tabungan</Text>
</Pressable>
<Pressable
style={styles.quickItem}
onPress={() => navigation.navigate('Riwayat')}
>
<View style={[styles.quickIconCircle, { backgroundColor: '#EDE9FE' }]}>
<Image
source={require('../../assets/chart-3d.png')}
style={styles.quickIcon}
resizeMode="contain"
/>
</View>
<Text style={styles.quickLabel}>Laporan</Text>
</Pressable>
</View>
{/* Statistik */}
<View style={styles.statsRow}>
<LinearGradient
colors={['#F0FDF4', '#DCFCE7']}
style={[styles.card, styles.statCard, styles.statGreenCard]}
>
<Image
source={require('../../assets/coin-3d.png')}
style={styles.statCoin}
resizeMode="contain"
/>
<View style={[styles.statIconCircle, { backgroundColor: colors.white }]}>
<Ionicons name="wallet" size={18} color="#166534" />
</View>
<Text style={styles.statLabel}>TABUNGAN</Text>
<Text style={[styles.statValue, { color: '#166534' }]}>
{formatRupiahShort(savings)}
</Text>
<Text style={styles.statHint}>otomatis tersisih ✨</Text>
</LinearGradient>
<LinearGradient
colors={['#FFFBEB', '#FEF3C7']}
style={[styles.card, styles.statCard, styles.statAmberCard]}
>
<Image
source={require('../../assets/fire-3d.png')}
style={styles.statFlame}
resizeMode="contain"
/>
<View style={[styles.statIconCircle, { backgroundColor: colors.white }]}>
<Ionicons name="flame" size={18} color="#D97706" />
</View>
<Text style={styles.statLabel}>STREAK NABUNG</Text>
<Text style={[styles.statValue, { color: '#92400E' }]}>🔥 {streak}</Text>
<Text style={styles.statHint}>minggu berturut-turut</Text>
</LinearGradient>
</View>
{/* Info bulan ini + proyeksi */}
<View style={styles.statsRow}>
<LinearGradient
colors={['#FEF2F2', '#FEE2E2']}
style={[styles.card, styles.statCard, styles.monthExpenseCard]}
>
<Image
source={require('../../assets/card-3d.png')}
style={styles.monthExpenseImg}
resizeMode="contain"
/>
<View style={[styles.statIconCircle, { backgroundColor: colors.white }]}>
<Ionicons name="arrow-up-circle" size={18} color="#B91C1C" />
</View>
<Text style={styles.statLabel}>PENGELUARAN BULAN INI</Text>
<Text style={[styles.statValue, { color: '#B91C1C' }]}>
{formatRupiahShort(monthExpense)}
</Text>
{topCategory ? (
<View style={styles.topCatRow}>
{topIcon3d ? (
<Image source={topIcon3d} style={styles.topCatIcon3d} resizeMode="contain" />
) : (
<Text style={styles.topCatEmoji}>{topCategory.categoryIcon}</Text>
)}
<Text style={styles.statHint} numberOfLines={1}>
Paling boros: {topCategory.categoryName}
</Text>
</View>
) : (
<Text style={styles.statHint}>Belum ada pengeluaran bulan ini</Text>
)}
</LinearGradient>
<LinearGradient
colors={['#F0FDF4', '#DCFCE7']}
style={[styles.card, styles.statCard, styles.monthProjectionCard]}
>
<Image
source={require('../../assets/chart-3d.png')}
style={styles.monthProjectionImg}
resizeMode="contain"
/>
<View style={[styles.statIconCircle, { backgroundColor: colors.white }]}>
<Ionicons name="trending-up" size={18} color="#166534" />
</View>
<Text style={styles.statLabel}>PROYEKSI TABUNGAN</Text>
<Text style={[styles.statValue, { color: '#166534' }]}>
{compare.current > 0 ? formatRupiahShort(projection) : '—'}
</Text>
<Text style={styles.statHint} numberOfLines={2}>
{compare.current > 0
? `estimasi akhir bulan (ritme ${formatRupiahShort(Math.round(perDay))}/hari)`
: 'Catat pemasukan untuk mulai proyeksi 🔮'}
</Text>
</LinearGradient>
</View>
{tip && (
<View style={[styles.card, styles.tipCard]}>
<Ionicons name="bulb" size={18} color="#D97706" />
<Text style={styles.tipText}>{tip}</Text>
</View>
)}
{!hasTodayActivity && (
<View style={[styles.card, styles.nudgeCard]}>
<Ionicons
name="notifications-outline"
size={18}
color={colors.primaryDark}
/>
<Text style={styles.nudgeText}>
Belum ada catatan hari ini — 5 detik aja, Mas!
</Text>
<Pressable
onPress={() => navigation.navigate('Catat')}
style={styles.nudgeButton}
>
<Text style={styles.nudgeButtonText}>Catat</Text>
</Pressable>
</View>
)}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Transaksi terakhir</Text>
<Pressable onPress={() => navigation.navigate('Riwayat')}>
<Text style={styles.sectionLink}>Lihat semua →</Text>
</Pressable>
</View>
{recent.length === 0 ? (
<View style={[styles.card, styles.emptyCard]}>
<Text style={styles.emptyEmoji}>📒</Text>
<Text style={styles.emptyTitle}>Masih kosong di sini</Text>
<Text style={styles.emptyText}>
Yuk catat transaksi pertamamu — biar asisten keuanganmu mulai bekerja!
</Text>
<Pressable
style={({ pressed }) => [styles.emptyButton, pressed && styles.pressed]}
onPress={() => navigation.navigate('Catat')}
>
<Text style={styles.emptyButtonText}>Mulai catat</Text>
</Pressable>
</View>
) : (
recent.map((t) => {
const icon3d = getCategoryIcon3d(t.categoryName);
return (
<View key={t.id} style={[styles.card, styles.txRow]}>
<View
style={[
styles.txIcon,
{
backgroundColor:
t.type === 'income' ? colors.incomeSoft : colors.expenseSoft,
},
]}
>
{icon3d ? (
<Image source={icon3d} style={styles.txIcon3d} resizeMode="contain" />
) : (
<Text style={styles.txIconText}>{t.categoryIcon ?? '•'}</Text>
)}
</View>
<View style={styles.txInfo}>
<Text style={styles.txName} numberOfLines={1}>
{t.note || t.categoryName || 'Tanpa kategori'}
</Text>
<Text style={styles.txDate}>
{new Date(t.occurredAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
})}
</Text>
</View>
<Text
style={[
styles.txAmount,
{ color: t.type === 'income' ? colors.income : colors.expense },
]}
>
{t.type === 'income' ? '+' : '-'}
{formatRupiah(t.amount)}
</Text>
</View>
);
})
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.background },
container: { flex: 1 },
content: { paddingBottom: 32 },
header: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 8, marginBottom: 12 },
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
logo: {
width: 44,
height: 50,
marginRight: 10,
marginLeft: -4,
},
headerText: { flex: 1 },
greeting: { fontSize: 22, fontWeight: '800', color: colors.text },
date: { fontSize: 13, color: colors.textMuted, marginTop: 2 },
avatarCircle: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: { color: colors.white, fontWeight: '800', fontSize: 16 },
quoteBubble: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: '#E6FFFA',
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 7,
marginTop: 12,
alignSelf: 'flex-start',
},
quoteText: { fontSize: 12, color: colors.primaryDark, fontWeight: '600', flexShrink: 1 },
balanceWrapper: { marginHorizontal: 16, marginTop: -8 },
balanceCard: {
borderRadius: 20,
padding: 16,
overflow: 'hidden',
shadowColor: colors.primaryDark,
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
ornamentCircleLarge: {
position: 'absolute',
right: -50,
bottom: -60,
width: 180,
height: 180,
borderRadius: 90,
backgroundColor: 'rgba(255,255,255,0.06)',
},
ornamentCircleMid: {
position: 'absolute',
left: -30,
top: -40,
width: 110,
height: 110,
borderRadius: 55,
backgroundColor: 'rgba(255,255,255,0.05)',
},
ornamentCircleSmall: {
position: 'absolute',
right: 130,
top: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: 'rgba(255,255,255,0.07)',
},
ornamentIcon: {
position: 'absolute',
right: -30,
bottom: -24,
width: 210,
height: 200,
opacity: 0.4,
transform: [{ perspective: 600 }, { rotateY: '-20deg' }],
},
balanceTop: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
cardLabelLight: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 1,
color: 'rgba(255,255,255,0.85)',
},
eyeButton: {
backgroundColor: 'rgba(255,255,255,0.15)',
borderRadius: 999,
padding: 7,
},
balanceValue: {
fontSize: 28,
fontWeight: '800',
color: colors.white,
marginTop: 4,
},
balanceHiddenRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 6,
},
hiddenRp: { fontSize: 22, fontWeight: '800', color: colors.white, marginRight: 2 },
starMask: { width: 19, height: 19 },
growthBadge: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
gap: 4,
backgroundColor: 'rgba(255,255,255,0.92)',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
marginTop: 6,
},
growthText: { fontSize: 12, fontWeight: '700' },
divider: { height: 1, backgroundColor: 'rgba(255,255,255,0.25)', marginVertical: 10 },
todayRow: { flexDirection: 'row', gap: 20 },
todayItem: { flex: 1 },
todayLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 4 },
todayLabelLight: { fontSize: 11, color: 'rgba(255,255,255,0.75)' },
todayValue: { fontSize: 14, fontWeight: '700', marginTop: 2 },
statsRow: { flexDirection: 'row', gap: 12, marginHorizontal: 16, marginTop: 12 },
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,
},
statCard: {
flex: 1,
overflow: 'hidden',
},
statGreenCard: {
borderColor: '#BBF7D0',
},
statAmberCard: {
borderColor: '#FDE68A',
},
monthExpenseCard: {
borderColor: '#FECACA',
},
monthExpenseImg: {
position: 'absolute',
right: -40,
bottom: 18,
width: 126,
height: 89,
opacity: 0.55,
transform: [{ perspective: 600 }, { rotateY: '-15deg' }],
},
monthProjectionCard: {
borderColor: '#BBF7D0',
},
monthProjectionImg: {
position: 'absolute',
right: -14,
bottom: 4,
width: 110,
height: 121,
opacity: 0.55,
transform: [{ perspective: 600 }, { rotateY: '-15deg' }],
},
tipCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginHorizontal: 16,
marginTop: 8,
backgroundColor: '#FFFBEB',
borderColor: '#FDE68A',
},
tipText: {
flex: 1,
fontSize: 12,
color: '#92400E',
fontWeight: '600',
lineHeight: 17,
},
nudgeCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginHorizontal: 16,
marginTop: 8,
backgroundColor: colors.primarySoft,
borderColor: colors.primarySoft,
},
nudgeText: {
flex: 1,
fontSize: 12,
color: colors.primaryDark,
fontWeight: '600',
},
nudgeButton: {
backgroundColor: colors.primary,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 6,
},
nudgeButtonText: { color: colors.white, fontWeight: '700', fontSize: 12 },
quickRow: {
flexDirection: 'row',
gap: 8,
marginHorizontal: 16,
marginTop: 12,
},
quickItem: { flex: 1, alignItems: 'center' },
quickIconCircle: {
width: 52,
height: 52,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
},
quickIcon: { width: 36, height: 36 },
quickLabel: { fontSize: 11, color: colors.textMuted, marginTop: 4, fontWeight: '600' },
hpCard: {
marginHorizontal: 16,
marginTop: 12,
},
hpHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
hpTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
hpTitle: { fontSize: 13, fontWeight: '800', color: colors.text },
hpPct: { fontSize: 16, fontWeight: '800' },
hpTrack: {
height: 14,
borderRadius: 7,
backgroundColor: '#E2E8F0',
overflow: 'hidden',
},
hpFill: {
height: '100%',
borderRadius: 7,
},
hpFooter: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 8,
},
hpStatus: { fontSize: 12, fontWeight: '800', letterSpacing: 0.5 },
hpMsg: { flex: 1, fontSize: 11, color: colors.textMuted, lineHeight: 15 },
statCoin: {
position: 'absolute',
right: -52,
top: 10,
width: 128,
height: 129,
opacity: 0.4,
},
statFlame: {
position: 'absolute',
right: -32,
top: 4,
width: 104,
height: 124,
opacity: 0.55,
},
statIconCircle: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10,
},
statLabel: {
fontSize: 10,
fontWeight: '700',
letterSpacing: 0.8,
color: colors.textMuted,
},
statValue: { fontSize: 19, fontWeight: '800', color: colors.text, marginTop: 3 },
statHint: { fontSize: 11, color: colors.textMuted, marginTop: 2, flexShrink: 1 },
topCatRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 2,
},
topCatIcon3d: { width: 18, height: 18 },
topCatEmoji: { fontSize: 14 },
pressed: { opacity: 0.85 },
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginHorizontal: 16,
marginBottom: 10,
marginTop: 22,
},
sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.text },
sectionLink: { fontSize: 13, color: colors.primary, fontWeight: '600' },
emptyCard: { alignItems: 'center', marginHorizontal: 16, paddingVertical: 26 },
emptyEmoji: { fontSize: 40, marginBottom: 8 },
emptyTitle: { fontSize: 15, fontWeight: '700', color: colors.text },
emptyText: {
color: colors.textMuted,
fontSize: 13,
textAlign: 'center',
marginTop: 4,
lineHeight: 19,
},
emptyButton: {
backgroundColor: colors.primary,
borderRadius: 10,
paddingHorizontal: 20,
paddingVertical: 10,
marginTop: 14,
},
emptyButtonText: { color: colors.white, fontWeight: '700', fontSize: 13 },
txRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
marginHorizontal: 16,
marginBottom: 8,
},
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' },
});
+353
View File
@@ -0,0 +1,353 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { RouteProp } from '@react-navigation/native';
import { useCallback, useEffect, useState } from 'react';
import {
Alert,
Image,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { colors } from '../theme/colors';
import { formatRibuan, formatRupiah } from '../utils/format';
import { getCategoryIcon3d } from '../utils/categoryIcons3d';
import {
getCategories,
getSettingNumber,
getTransactionById,
updateTransaction,
} from '../db/repositories';
import type { Category, TransactionType } from '../types';
import type { RootStackParamList } from '../navigation';
export function EditTransactionScreen() {
const navigation = useNavigation();
const route = useRoute<RouteProp<RootStackParamList, 'EditTransaksi'>>();
const { transactionId } = route.params;
const [type, setType] = useState<TransactionType>('expense');
const [amountText, setAmountText] = useState('');
const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [note, setNote] = useState('');
const [percent, setPercent] = useState(10);
const [saving, setSaving] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
(async () => {
const tx = await getTransactionById(transactionId);
if (!tx) {
Alert.alert('Transaksi tidak ditemukan', 'Data mungkin sudah dihapus.');
navigation.goBack();
return;
}
setType(tx.type);
setAmountText(String(tx.amount));
setSelectedCategoryId(tx.categoryId);
setNote(tx.note);
const p = await getSettingNumber('auto_save_percent', 10);
setPercent(p);
const cats = await getCategories(tx.type);
setCategories(cats);
setLoaded(true);
})();
}, [transactionId, navigation]);
const amount = Number(amountText.replace(/[^0-9]/g, '')) || 0;
const savedAmount = type === 'income' ? Math.round((amount * percent) / 100) : 0;
const switchType = (t: TransactionType) => {
setType(t);
loadCategories(t);
};
const loadCategories = useCallback(async (t: TransactionType) => {
const cats = await getCategories(t);
setCategories(cats);
}, []);
const handleSave = async () => {
if (amount <= 0) {
Alert.alert('Nominal kosong', 'Isi nominal dulu ya, Mas.');
return;
}
setSaving(true);
try {
await updateTransaction(transactionId, {
type,
amount,
categoryId: selectedCategoryId,
note: note.trim(),
});
Alert.alert('Tersimpan ✅', 'Transaksi berhasil diperbarui.', [
{ text: 'Oke', onPress: () => navigation.goBack() },
]);
} catch (e) {
Alert.alert('Gagal menyimpan', String(e));
} finally {
setSaving(false);
}
};
if (!loaded) return null;
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Toggle tipe */}
<View style={styles.toggleRow}>
<Pressable
style={[styles.toggleBtn, type === 'income' && styles.toggleIncome]}
onPress={() => switchType('income')}
>
<Text
style={[
styles.toggleText,
type === 'income' && { color: colors.income, fontWeight: '700' },
]}
>
+ Pemasukan
</Text>
</Pressable>
<Pressable
style={[styles.toggleBtn, type === 'expense' && styles.toggleExpense]}
onPress={() => switchType('expense')}
>
<Text
style={[
styles.toggleText,
type === 'expense' && { color: colors.expense, fontWeight: '700' },
]}
>
- Pengeluaran
</Text>
</Pressable>
</View>
{/* Nominal */}
<LinearGradient
colors={
type === 'income' ? ['#F0FDF4', '#DCFCE7'] : ['#FEF2F2', '#FEE2E2']
}
style={[
styles.card,
styles.amountCard,
type === 'income' ? styles.amountIncome : styles.amountExpense,
]}
>
<Image
source={require('../../assets/coin-3d.png')}
style={styles.amountCoin}
resizeMode="contain"
/>
<Text style={styles.cardLabel}>NOMINAL</Text>
<TextInput
style={styles.amountInput}
value={amountText ? `Rp ${formatRibuan(amountText)}` : ''}
onChangeText={(t) => setAmountText(t.replace(/[^0-9]/g, ''))}
placeholder="Rp 0"
placeholderTextColor={colors.border}
keyboardType="number-pad"
autoFocus
/>
</LinearGradient>
{type === 'income' && amount > 0 && (
<View style={[styles.card, styles.saveHintCard]}>
<Image
source={require('../../assets/coin-3d.png')}
style={styles.saveHintCoin}
resizeMode="contain"
/>
<Text style={styles.saveHintText}>
{formatRupiah(savedAmount)} otomatis masuk Tabungan ({percent}%)
</Text>
</View>
)}
{/* Kategori */}
<Text style={styles.sectionTitle}>Kategori</Text>
<View style={styles.categoryGrid}>
{categories.map((c) => {
const selected = c.id === selectedCategoryId;
const icon3d = getCategoryIcon3d(c.name);
return (
<Pressable
key={c.id}
style={[styles.categoryChip, selected && styles.categoryChipSelected]}
onPress={() => setSelectedCategoryId(c.id)}
>
{icon3d ? (
<Image source={icon3d} style={styles.categoryIcon3d} resizeMode="contain" />
) : (
<Text style={styles.categoryIcon}>{c.icon}</Text>
)}
<Text
style={[
styles.categoryName,
selected && { color: colors.primaryDark, fontWeight: '700' },
]}
>
{c.name}
</Text>
</Pressable>
);
})}
</View>
{/* Catatan */}
<Text style={styles.sectionTitle}>Catatan (opsional)</Text>
<TextInput
style={[styles.card, styles.noteInput]}
value={note}
onChangeText={setNote}
placeholder="Misal: makan siang di warteg"
placeholderTextColor={colors.textMuted}
/>
</ScrollView>
{/* Tombol Simpan fixed */}
<View style={styles.saveBar}>
<Pressable
onPress={handleSave}
disabled={amount <= 0 || saving}
style={({ pressed }) => [styles.savePressable, pressed && styles.pressed]}
>
<View style={styles.saveButton}>
<Text style={styles.saveButtonText}>
{saving ? 'Menyimpan...' : 'Simpan Perubahan'}
</Text>
</View>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.background },
container: { flex: 1 },
content: { padding: 16, paddingBottom: 32 },
toggleRow: {
flexDirection: 'row',
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: colors.border,
padding: 4,
marginBottom: 12,
},
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,
},
amountCard: {
marginBottom: 12,
overflow: 'hidden',
},
amountIncome: { borderColor: '#BBF7D0' },
amountExpense: { borderColor: '#FECACA' },
amountCoin: {
position: 'absolute',
right: -14,
top: -4,
width: 114,
height: 115,
opacity: 0.3,
transform: [{ perspective: 600 }, { rotateY: '-20deg' }],
},
cardLabel: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 1,
color: colors.textMuted,
},
amountInput: {
fontSize: 34,
fontWeight: '800',
color: colors.text,
paddingVertical: 4,
},
saveHintCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.primarySoft,
borderColor: colors.primarySoft,
marginBottom: 12,
},
saveHintCoin: { width: 26, height: 26 },
saveHintText: { color: colors.primaryDark, fontWeight: '600', fontSize: 13, flex: 1 },
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.text,
marginBottom: 8,
marginTop: 8,
},
categoryGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
categoryChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 8,
},
categoryChipSelected: {
borderColor: colors.primary,
backgroundColor: colors.primarySoft,
},
categoryIcon: { fontSize: 16 },
categoryIcon3d: { width: 22, height: 22 },
categoryName: { fontSize: 13, color: colors.text },
noteInput: { paddingVertical: 10, fontSize: 14, color: colors.text },
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 },
});
+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 },
});
+336
View File
@@ -0,0 +1,336 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useNavigation } from '@react-navigation/native';
import { useCallback, useEffect, useState } from 'react';
import {
Alert,
Image,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { colors } from '../theme/colors';
import { formatRibuan, formatRupiah } from '../utils/format';
import { getCategoryIcon3d } from '../utils/categoryIcons3d';
import { addRecurring, getCategories } from '../db/repositories';
import type { Category, TransactionType } from '../types';
export function RecurringFormScreen() {
const navigation = useNavigation();
const [type, setType] = useState<TransactionType>('expense');
const [amountText, setAmountText] = useState('');
const [dayText, setDayText] = useState('1');
const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [note, setNote] = useState('');
const [saving, setSaving] = useState(false);
const loadCategories = useCallback(async (t: TransactionType) => {
const cats = await getCategories(t);
setCategories(cats);
setSelectedCategoryId(cats[0]?.id ?? null);
}, []);
useEffect(() => {
loadCategories(type);
}, [loadCategories, type]);
const amount = Number(amountText.replace(/[^0-9]/g, '')) || 0;
const dayOfMonth = Math.min(31, Math.max(1, Number(dayText.replace(/[^0-9]/g, '')) || 1));
const switchType = (t: TransactionType) => {
setType(t);
loadCategories(t);
};
const handleSave = async () => {
if (amount <= 0) {
Alert.alert('Nominal kosong', 'Isi nominal dulu ya, Mas.');
return;
}
setSaving(true);
try {
await addRecurring({
type,
amount,
categoryId: selectedCategoryId,
note: note.trim(),
dayOfMonth,
});
Alert.alert('Tersimpan ✅', 'Transaksi berulang aktif. Sisih akan dibuat otomatis tiap bulan.', [
{ text: 'Oke', onPress: () => navigation.goBack() },
]);
} catch (e) {
Alert.alert('Gagal menyimpan', String(e));
} finally {
setSaving(false);
}
};
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.infoText}>
🔁 Transaksi ini akan dibuat otomatis setiap bulan pada tanggal yang
ditentukan.
</Text>
{/* Toggle tipe */}
<View style={styles.toggleRow}>
<Pressable
style={[styles.toggleBtn, type === 'income' && styles.toggleIncome]}
onPress={() => switchType('income')}
>
<Text
style={[
styles.toggleText,
type === 'income' && { color: colors.income, fontWeight: '700' },
]}
>
+ Pemasukan
</Text>
</Pressable>
<Pressable
style={[styles.toggleBtn, type === 'expense' && styles.toggleExpense]}
onPress={() => switchType('expense')}
>
<Text
style={[
styles.toggleText,
type === 'expense' && { color: colors.expense, fontWeight: '700' },
]}
>
- Pengeluaran
</Text>
</Pressable>
</View>
{/* Nominal */}
<LinearGradient
colors={
type === 'income' ? ['#F0FDF4', '#DCFCE7'] : ['#FEF2F2', '#FEE2E2']
}
style={[
styles.card,
styles.amountCard,
type === 'income' ? styles.amountIncome : styles.amountExpense,
]}
>
<Image
source={require('../../assets/coin-3d.png')}
style={styles.amountCoin}
resizeMode="contain"
/>
<Text style={styles.cardLabel}>NOMINAL</Text>
<TextInput
style={styles.amountInput}
value={amountText ? `Rp ${formatRibuan(amountText)}` : ''}
onChangeText={(t) => setAmountText(t.replace(/[^0-9]/g, ''))}
placeholder="Rp 0"
placeholderTextColor={colors.border}
keyboardType="number-pad"
/>
</LinearGradient>
{/* Tanggal */}
<Text style={styles.sectionTitle}>Tanggal setiap bulan</Text>
<View style={[styles.card, styles.dayCard]}>
<Ionicons name="calendar" size={18} color={colors.primaryDark} />
<TextInput
style={styles.dayInput}
value={dayText}
onChangeText={(t) => setDayText(t.replace(/[^0-9]/g, ''))}
placeholder="1"
placeholderTextColor={colors.border}
keyboardType="number-pad"
maxLength={2}
/>
<Text style={styles.dayHint}>
{dayOfMonth > 28
? 'Tanggal > 28 hanya aktif di bulan yang punya tanggal itu'
: 'Setiap bulan pada tanggal ini'}
</Text>
</View>
{/* Kategori */}
<Text style={styles.sectionTitle}>Kategori</Text>
<View style={styles.categoryGrid}>
{categories.map((c) => {
const selected = c.id === selectedCategoryId;
const icon3d = getCategoryIcon3d(c.name);
return (
<Pressable
key={c.id}
style={[styles.categoryChip, selected && styles.categoryChipSelected]}
onPress={() => setSelectedCategoryId(c.id)}
>
{icon3d ? (
<Image source={icon3d} style={styles.categoryIcon3d} resizeMode="contain" />
) : (
<Text style={styles.categoryIcon}>{c.icon}</Text>
)}
<Text
style={[
styles.categoryName,
selected && { color: colors.primaryDark, fontWeight: '700' },
]}
>
{c.name}
</Text>
</Pressable>
);
})}
</View>
{/* Catatan */}
<Text style={styles.sectionTitle}>Catatan (opsional)</Text>
<TextInput
style={[styles.card, styles.noteInput]}
value={note}
onChangeText={setNote}
placeholder="Misal: Gaji bulanan"
placeholderTextColor={colors.textMuted}
/>
</ScrollView>
<View style={styles.saveBar}>
<Pressable
onPress={handleSave}
disabled={amount <= 0 || saving}
style={({ pressed }) => [styles.savePressable, pressed && styles.pressed]}
>
<View style={styles.saveButton}>
<Text style={styles.saveButtonText}>
{saving ? 'Menyimpan...' : 'Simpan'}
</Text>
</View>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.background },
container: { flex: 1 },
content: { padding: 16, paddingBottom: 32 },
infoText: {
fontSize: 13,
color: colors.textMuted,
lineHeight: 19,
marginBottom: 12,
},
toggleRow: {
flexDirection: 'row',
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: colors.border,
padding: 4,
marginBottom: 12,
},
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,
},
amountCard: { marginBottom: 12, overflow: 'hidden' },
amountIncome: { borderColor: '#BBF7D0' },
amountExpense: { borderColor: '#FECACA' },
amountCoin: {
position: 'absolute',
right: -14,
top: -4,
width: 114,
height: 115,
opacity: 0.3,
transform: [{ perspective: 600 }, { rotateY: '-20deg' }],
},
cardLabel: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 1,
color: colors.textMuted,
},
amountInput: { fontSize: 34, fontWeight: '800', color: colors.text, paddingVertical: 4 },
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.text,
marginBottom: 8,
marginTop: 8,
},
dayCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 12,
},
dayInput: {
fontSize: 22,
fontWeight: '800',
color: colors.text,
minWidth: 44,
borderBottomWidth: 2,
borderBottomColor: colors.primary,
paddingVertical: 2,
textAlign: 'center',
},
dayHint: { flex: 1, fontSize: 11, color: colors.textMuted, lineHeight: 15 },
categoryGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
categoryChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 8,
},
categoryChipSelected: {
borderColor: colors.primary,
backgroundColor: colors.primarySoft,
},
categoryIcon: { fontSize: 16 },
categoryIcon3d: { width: 22, height: 22 },
categoryName: { fontSize: 13, color: colors.text },
noteInput: { paddingVertical: 10, fontSize: 14, color: colors.text },
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 },
});
+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 },
});
+543
View File
@@ -0,0 +1,543 @@
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,
TextInput,
View,
} from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { colors } from '../theme/colors';
import { getDb } from '../db/database';
import {
deleteRecurring,
getRecurring,
getSettingNumber,
setSetting,
} from '../db/repositories';
import { formatRupiah } from '../utils/format';
import type { RootStackParamList } from '../navigation';
const PRESETS = [5, 10, 15, 20];
interface BackupPayload {
app: string;
version: number;
exportedAt: string;
categories: Array<{
id: number;
name: string;
type: string;
icon: string;
is_default: number;
}>;
transactions: Array<{
id: number;
type: string;
amount: number;
category_id: number | null;
note: string;
occurred_at: string;
created_at: string;
}>;
savings_events: Array<{
id: number;
transaction_id: number;
amount: number;
occurred_at: string;
}>;
settings: Array<{ key: string; value: string }>;
}
export function SettingsScreen() {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [percent, setPercent] = useState(10);
const [customPercent, setCustomPercent] = useState('');
const [busy, setBusy] = useState(false);
const [recurring, setRecurring] = useState<
Array<{
id: number;
type: 'income' | 'expense';
amount: number;
categoryName: string | null;
note: string;
dayOfMonth: number;
}>
>([]);
useFocusEffect(
useCallback(() => {
let active = true;
getSettingNumber('auto_save_percent', 10).then((p) => {
if (active) setPercent(p);
});
getRecurring().then((r) => {
if (active) setRecurring(r);
});
return () => {
active = false;
};
}, [])
);
const handleDeleteRecurring = (id: number, label: string) => {
Alert.alert(
'Hapus transaksi berulang?',
`"${label}" tidak akan dibuat otomatis lagi.`,
[
{ text: 'Batal', style: 'cancel' },
{
text: 'Hapus',
style: 'destructive',
onPress: async () => {
await deleteRecurring(id);
setRecurring(await getRecurring());
},
},
]
);
};
const applyPercent = async (p: number) => {
const value = Math.max(0, Math.min(100, p));
setPercent(value);
setCustomPercent('');
await setSetting('auto_save_percent', String(value));
};
const handleExport = async () => {
setBusy(true);
try {
const db = getDb();
const categories = await db.getAllAsync('SELECT * FROM categories');
const transactions = await db.getAllAsync('SELECT * FROM transactions');
const savings_events = await db.getAllAsync('SELECT * FROM savings_events');
const settings = await db.getAllAsync('SELECT * FROM settings');
const payload: BackupPayload = {
app: 'sisih',
version: 1,
exportedAt: new Date().toISOString(),
categories: categories as BackupPayload['categories'],
transactions: transactions as BackupPayload['transactions'],
savings_events: savings_events as BackupPayload['savings_events'],
settings: settings as BackupPayload['settings'],
};
const stamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.slice(0, 16);
const file = new File(Paths.document, `sisih-backup-${stamp}.json`);
file.write(JSON.stringify(payload, null, 2));
if (!(await Sharing.isAvailableAsync())) {
Alert.alert('Tidak bisa berbagi file', 'Fitur share tidak tersedia di perangkat ini.');
return;
}
await Sharing.shareAsync(file.uri, {
mimeType: 'application/json',
dialogTitle: 'Backup Sisih',
});
} catch (e) {
Alert.alert('Export gagal', String(e));
} finally {
setBusy(false);
}
};
const doImport = async () => {
setBusy(true);
try {
const res = await File.pickFileAsync({ mimeTypes: ['application/json'] });
if (res.canceled) return;
const file = res.result;
const text = await file.text();
const payload = JSON.parse(text) as BackupPayload;
if (payload.app !== 'sisih' || !Array.isArray(payload.transactions)) {
Alert.alert('File tidak valid', 'File ini bukan backup Kantongku.');
return;
}
const db = getDb();
await db.withTransactionAsync(async () => {
await db.execAsync(
'DELETE FROM savings_events; DELETE FROM transactions; DELETE FROM categories; DELETE FROM settings;'
);
for (const c of payload.categories) {
await db.runAsync(
'INSERT INTO categories (id, name, type, icon, is_default) VALUES (?, ?, ?, ?, ?)',
[c.id, c.name, c.type, c.icon, c.is_default]
);
}
for (const t of payload.transactions) {
await db.runAsync(
'INSERT INTO transactions (id, type, amount, category_id, note, occurred_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[t.id, t.type, t.amount, t.category_id, t.note, t.occurred_at, t.created_at]
);
}
for (const s of payload.savings_events) {
await db.runAsync(
'INSERT INTO savings_events (id, transaction_id, amount, occurred_at) VALUES (?, ?, ?, ?)',
[s.id, s.transaction_id, s.amount, s.occurred_at]
);
}
for (const st of payload.settings) {
await db.runAsync('INSERT INTO settings (key, value) VALUES (?, ?)', [
st.key,
st.value,
]);
}
});
Alert.alert('Import selesai ✅', 'Data backup berhasil dipulihkan.');
setPercent(await getSettingNumber('auto_save_percent', 10));
} catch (e) {
Alert.alert('Import gagal', String(e));
} finally {
setBusy(false);
}
};
const handleImport = () => {
Alert.alert(
'Import backup?',
'Semua data yang ada saat ini akan DIGANTI dengan isi file backup. Lanjutkan?',
[
{ text: 'Batal', style: 'cancel' },
{ text: 'Import', style: 'destructive', onPress: doImport },
]
);
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Auto-sisih */}
<LinearGradient
colors={[colors.primary, colors.primaryDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.percentCard}
>
<View style={styles.ornCircleLarge} />
<View style={styles.ornCircleSmall} />
<Image
source={require('../../assets/gear-3d.png')}
style={styles.percentCoin}
resizeMode="contain"
/>
<Text style={styles.percentLabel}>AUTO-SISIH OTOMATIS</Text>
<Text style={styles.percentValue}>{percent}%</Text>
<Text style={styles.percentHint}>
dari setiap pemasukan otomatis masuk Tabungan
</Text>
<View style={styles.presetRow}>
{PRESETS.map((p) => (
<Pressable
key={p}
style={[
styles.presetChip,
p === percent && styles.presetChipActive,
]}
onPress={() => applyPercent(p)}
>
<Text
style={[
styles.presetText,
p === percent && styles.presetTextActive,
]}
>
{p}%
</Text>
</Pressable>
))}
</View>
<View style={styles.customRow}>
<TextInput
style={styles.customInput}
value={customPercent}
onChangeText={(t) => setCustomPercent(t.replace(/[^0-9]/g, ''))}
placeholder="Atur sendiri"
placeholderTextColor="rgba(255,255,255,0.5)"
keyboardType="number-pad"
/>
<Pressable
style={styles.customButton}
onPress={() => {
const n = Number(customPercent);
if (n > 0) applyPercent(n);
}}
>
<Text style={styles.customButtonText}>Terapkan</Text>
</Pressable>
</View>
</LinearGradient>
{/* Transaksi Berulang */}
<Text style={styles.sectionTitle}>Transaksi Berulang</Text>
<View style={[styles.card, styles.dataCard]}>
{recurring.length === 0 ? (
<Text style={styles.emptyRecurringText}>
Belum ada. Tambahkan gaji/langganan bulanan biar dicatat otomatis!
</Text>
) : (
recurring.map((r) => (
<View key={r.id} style={styles.recurringRow}>
<View style={styles.dataIconCircle}>
<Ionicons name="repeat" size={18} color={colors.primaryDark} />
</View>
<View style={styles.dataInfo}>
<Text style={styles.dataTitle} numberOfLines={1}>
{r.note || r.categoryName || 'Transaksi'}
</Text>
<Text style={styles.dataHint}>
Tiap tanggal {r.dayOfMonth} · {r.type === 'income' ? '+' : '-'}
{formatRupiah(r.amount)}
</Text>
</View>
<Pressable
onPress={() =>
handleDeleteRecurring(
r.id,
r.note || r.categoryName || 'Transaksi'
)
}
hitSlop={8}
>
<Ionicons name="trash-outline" size={18} color={colors.expense} />
</Pressable>
</View>
))
)}
<View style={styles.divider} />
<Pressable
style={({ pressed }) => [styles.dataRow, pressed && styles.pressed]}
onPress={() => navigation.navigate('RecurringForm')}
>
<View style={[styles.dataIconCircle, { backgroundColor: '#FEF3C7' }]}>
<Ionicons name="add" size={20} color="#D97706" />
</View>
<View style={styles.dataInfo}>
<Text style={styles.dataTitle}>Tambah transaksi berulang</Text>
<Text style={styles.dataHint}>
Gaji, cicilan, langganan — otomatis tiap bulan
</Text>
</View>
</Pressable>
</View>
{/* Data */}
<Text style={styles.sectionTitle}>Data</Text>
<View style={[styles.card, styles.dataCard]}>
<Pressable
style={({ pressed }) => [styles.dataRow, pressed && styles.pressed]}
onPress={handleExport}
disabled={busy}
>
<View style={styles.dataIconCircle}>
<Image
source={require('../../assets/export-3d.png')}
style={styles.dataIcon3d}
resizeMode="contain"
/>
</View>
<View style={styles.dataInfo}>
<Text style={styles.dataTitle}>Export backup</Text>
<Text style={styles.dataHint}>
Simpan salinan data ke file JSON
</Text>
</View>
</Pressable>
<View style={styles.divider} />
<Pressable
style={({ pressed }) => [styles.dataRow, pressed && styles.pressed]}
onPress={handleImport}
disabled={busy}
>
<View style={styles.dataIconCircle}>
<Image
source={require('../../assets/import-3d.png')}
style={styles.dataIcon3d}
resizeMode="contain"
/>
</View>
<View style={styles.dataInfo}>
<Text style={styles.dataTitle}>Import backup</Text>
<Text style={styles.dataHint}>Pulihkan data dari file JSON</Text>
</View>
</Pressable>
</View>
{/* Tentang */}
<Text style={styles.sectionTitle}>Tentang</Text>
<View style={[styles.card, styles.dataCard]}>
<View style={styles.dataRow}>
<View style={styles.dataIconCircle}>
<Image
source={require('../../assets/phone-3d.png')}
style={styles.dataIcon3d}
resizeMode="contain"
/>
</View>
<View style={styles.dataInfo}>
<Text style={styles.dataTitle}>Kantongku v1.0.0</Text>
<Text style={styles.dataHint}>
Semua data tersimpan offline di perangkat ini. Tanpa server, tanpa
internet, privasi terjaga. 🔒
</Text>
</View>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
content: { padding: 16, paddingBottom: 32 },
sectionTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.text,
marginBottom: 10,
marginTop: 6,
},
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,
},
// Hero auto-sisih
percentCard: {
borderRadius: 20,
padding: 18,
overflow: 'hidden',
marginBottom: 20,
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)',
},
percentCoin: {
position: 'absolute',
right: -52,
bottom: 20,
width: 236,
height: 238,
opacity: 0.45,
transform: [{ perspective: 600 }, { rotateY: '-35deg' }, { rotateZ: '-15deg' }],
},
percentLabel: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 1,
color: 'rgba(255,255,255,0.85)',
paddingRight: 110,
},
percentValue: {
fontSize: 40,
fontWeight: '800',
color: colors.white,
marginTop: 6,
},
percentHint: { fontSize: 12, color: 'rgba(255,255,255,0.85)', marginTop: 4 },
presetRow: { flexDirection: 'row', gap: 8, marginTop: 16 },
presetChip: {
flex: 1,
alignItems: 'center',
paddingVertical: 10,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.15)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.25)',
},
presetChipActive: {
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: colors.white,
},
presetText: { fontSize: 14, color: colors.white, fontWeight: '600' },
presetTextActive: { color: colors.primaryDark, fontWeight: '800' },
customRow: { flexDirection: 'row', gap: 8, marginTop: 10 },
customInput: {
flex: 1,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
color: colors.white,
backgroundColor: 'rgba(255,255,255,0.15)',
},
customButton: {
backgroundColor: colors.white,
borderRadius: 10,
paddingHorizontal: 18,
justifyContent: 'center',
},
customButtonText: { color: colors.primaryDark, fontWeight: '700', fontSize: 13 },
// Data
dataCard: { marginBottom: 20 },
emptyRecurringText: {
fontSize: 13,
color: colors.textMuted,
lineHeight: 19,
marginBottom: 8,
},
recurringRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 8,
},
dataRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 6 },
dataIconCircle: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.primarySoft,
alignItems: 'center',
justifyContent: 'center',
},
dataIcon3d: { width: 34, height: 34 },
dataInfo: { flex: 1 },
dataTitle: { fontSize: 14, fontWeight: '600', color: colors.text },
dataHint: { fontSize: 12, color: colors.textMuted, marginTop: 2, lineHeight: 17 },
divider: { height: 1, backgroundColor: colors.border, marginVertical: 10 },
pressed: { opacity: 0.7 },
});
+176
View File
@@ -0,0 +1,176 @@
import { useNavigation } 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 { formatRibuan, formatRupiah } from '../utils/format';
import { addSavingsTarget } from '../db/repositories';
const EMOJIS = ['🎯', '💼', '🏠', '✈️', '🚗', '💍', '📱', '💻', '🎮', '🐷', '🛡️', '🎁'];
export function TargetFormScreen() {
const navigation = useNavigation();
const [name, setName] = useState('');
const [amountText, setAmountText] = useState('');
const [icon, setIcon] = useState('🎯');
const [saving, setSaving] = useState(false);
const amount = Number(amountText.replace(/[^0-9]/g, '')) || 0;
const handleSave = async () => {
if (!name.trim()) {
Alert.alert('Nama kosong', 'Kasih nama targetnya dulu, Mas.');
return;
}
if (amount <= 0) {
Alert.alert('Nominal kosong', 'Isi nominal targetnya dulu ya.');
return;
}
setSaving(true);
try {
await addSavingsTarget({ name: name.trim(), targetAmount: amount, icon });
Alert.alert('Target dibuat 🎯', 'Sekarang tinggal kejar!', [
{ text: 'Oke', onPress: () => navigation.goBack() },
]);
} catch (e) {
Alert.alert('Gagal menyimpan', String(e));
} finally {
setSaving(false);
}
};
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.infoText}>
🎯 Target yang nyata bikin nabung lebih semangat. Progress dihitung dari
total tabunganmu.
</Text>
<Text style={styles.sectionTitle}>Nama target</Text>
<TextInput
style={[styles.card, styles.input]}
value={name}
onChangeText={setName}
placeholder="Misal: Dana Darurat, Liburan, PS5..."
placeholderTextColor={colors.textMuted}
/>
<Text style={styles.sectionTitle}>Nominal target</Text>
<TextInput
style={[styles.card, styles.input, styles.amountInput]}
value={amountText ? `Rp ${formatRibuan(amountText)}` : ''}
onChangeText={(t) => setAmountText(t.replace(/[^0-9]/g, ''))}
placeholder="Rp 0"
placeholderTextColor={colors.border}
keyboardType="number-pad"
/>
<Text style={styles.sectionTitle}>Ikon</Text>
<View style={styles.emojiGrid}>
{EMOJIS.map((e) => (
<Pressable
key={e}
style={[styles.emojiChip, icon === e && styles.emojiChipActive]}
onPress={() => setIcon(e)}
>
<Text style={styles.emojiText}>{e}</Text>
</Pressable>
))}
</View>
</ScrollView>
<View style={styles.saveBar}>
<Pressable
onPress={handleSave}
disabled={saving}
style={({ pressed }) => [styles.savePressable, pressed && styles.pressed]}
>
<View style={styles.saveButton}>
<Text style={styles.saveButtonText}>
{saving ? 'Menyimpan...' : 'Buat Target'}
</Text>
</View>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.background },
container: { flex: 1 },
content: { padding: 16, paddingBottom: 32 },
infoText: {
fontSize: 13,
color: colors.textMuted,
lineHeight: 19,
marginBottom: 12,
},
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.text,
marginBottom: 8,
marginTop: 8,
},
card: {
backgroundColor: colors.surface,
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
},
input: { paddingVertical: 12, fontSize: 15, color: colors.text },
amountInput: { fontSize: 24, fontWeight: '800' },
emojiGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
emojiChip: {
width: 52,
height: 52,
borderRadius: 14,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
emojiChipActive: {
borderColor: colors.primary,
backgroundColor: colors.primarySoft,
},
emojiText: { fontSize: 24 },
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 },
});
+15
View File
@@ -0,0 +1,15 @@
export const colors = {
background: '#F6F7F9',
surface: '#FFFFFF',
primary: '#0D9488',
primaryDark: '#0F766E',
primarySoft: '#CCFBF1',
income: '#16A34A',
incomeSoft: '#DCFCE7',
expense: '#DC2626',
expenseSoft: '#FEE2E2',
text: '#0F172A',
textMuted: '#64748B',
border: '#E2E8F0',
white: '#FFFFFF',
};
+68
View File
@@ -0,0 +1,68 @@
export type TransactionType = 'income' | 'expense';
export interface Category {
id: number;
name: string;
type: TransactionType;
icon: string;
isDefault: boolean;
}
export interface Transaction {
id: number;
type: TransactionType;
amount: number;
categoryId: number | null;
note: string;
occurredAt: string;
createdAt: string;
}
export interface TransactionWithCategory extends Transaction {
categoryName: string | null;
categoryIcon: string | null;
}
export interface SavingsEvent {
id: number;
transactionId: number;
amount: number;
occurredAt: string;
transactionNote: string | null;
transactionAmount: number;
}
export interface AddTransactionInput {
type: TransactionType;
amount: number;
categoryId?: number | null;
note?: string;
occurredAt?: Date;
}
export interface Recurring {
id: number;
type: TransactionType;
amount: number;
categoryId: number | null;
note: string;
dayOfMonth: number;
active: boolean;
createdAt: string;
}
export interface RecurringInput {
type: TransactionType;
amount: number;
categoryId?: number | null;
note?: string;
dayOfMonth: number;
}
export interface SavingsTarget {
id: number;
name: string;
targetAmount: number;
icon: string;
createdAt: string;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Mapping kategori -> icon 3D.
* Kategori yang belum punya versi 3D otomatis fallback ke emoji.
*/
export const CATEGORY_3D: Record<string, any> = {
Makanan: require('../../assets/cat-makanan.png'),
Transportasi: require('../../assets/cat-transportasi.png'),
Belanja: require('../../assets/cat-belanja.png'),
Gaji: require('../../assets/cat-gaji.png'),
};
export function getCategoryIcon3d(categoryName: string | null): any | null {
if (!categoryName) return null;
return CATEGORY_3D[categoryName] ?? null;
}
+67
View File
@@ -0,0 +1,67 @@
export function formatRupiah(value: number): string {
const formatted = Math.abs(value).toLocaleString('id-ID');
return `${value < 0 ? '-' : ''}Rp ${formatted}`;
}
/** Format angka mentah (string digit) jadi ribuan ber-titik, misal "50000" -> "50.000" */
export function formatRibuan(raw: string): string {
if (!raw) return '';
const n = Number(raw);
if (!Number.isFinite(n)) return raw;
return n.toLocaleString('id-ID');
}
export function formatRupiahShort(value: number): string {
if (Math.abs(value) >= 1_000_000) {
const juta = value / 1_000_000;
return `Rp ${juta.toLocaleString('id-ID', { maximumFractionDigits: 1 })} jt`;
}
if (Math.abs(value) >= 1_000) {
const ribu = value / 1_000;
return `Rp ${ribu.toLocaleString('id-ID', { maximumFractionDigits: 1 })} rb`;
}
return formatRupiah(value);
}
export function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
export function formatDateTime(iso: string): string {
const d = new Date(iso);
const date = d.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });
const time = d.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
});
return `${date} · ${time}`;
}
export function todayLabel(): string {
return new Date().toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
/** Kunci minggu ISO sederhana: tanggal Senin (YYYY-MM-DD) */
export function isoWeekKey(d: Date): string {
const date = new Date(d);
const day = date.getDay() || 7; // Senin=1, ..., Minggu=7
date.setDate(date.getDate() - (day - 1));
date.setHours(0, 0, 0, 0);
return date.toISOString().slice(0, 10);
}
export function shiftWeekKey(key: string, deltaWeeks: number): string {
const monday = new Date(`${key}T00:00:00Z`);
monday.setUTCDate(monday.getUTCDate() + deltaWeeks * 7);
return monday.toISOString().slice(0, 10);
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}