Kantongku: aplikasi pencatat keuangan pribadi offline-first
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||