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
|
||||||
|
}
|
||||||
|
}
|
||||||