Compare commits

...
10 Commits
17 changed files with 374 additions and 92 deletions
+35 -11
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { Component, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { ActivityIndicator, StatusBar, StyleSheet, Text, View } from 'react-native'; import { ActivityIndicator, StatusBar, StyleSheet, Text, View } from 'react-native';
import * as SplashScreen from 'expo-splash-screen'; import * as SplashScreen from 'expo-splash-screen';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
@@ -11,6 +12,27 @@ import { colors } from './src/theme/colors';
SplashScreen.preventAutoHideAsync().catch(() => {}); SplashScreen.preventAutoHideAsync().catch(() => {});
/** Tangkap error render agar tidak force-close: tampilkan layar aman. */
class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; message: string }> {
state = { hasError: false, message: '' };
static getDerivedStateFromError(e: unknown) {
return { hasError: true, message: e instanceof Error ? e.message : String(e) };
}
render() {
if (this.state.hasError) {
return (
<View style={styles.center}>
<Text style={styles.errorTitle}>Terjadi kesalahan 😢</Text>
<Text style={styles.errorText}>{this.state.message}</Text>
</View>
);
}
return this.props.children;
}
}
export default function App() { export default function App() {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -62,16 +84,18 @@ export default function App() {
} }
return ( return (
<SafeAreaProvider> <ErrorBoundary>
<NavigationContainer> <SafeAreaProvider>
<RootNavigator initialRoute={initialRoute} /> <NavigationContainer>
</NavigationContainer> <RootNavigator initialRoute={initialRoute} />
<StatusBar </NavigationContainer>
barStyle="light-content" <StatusBar
translucent={false} barStyle="light-content"
backgroundColor={colors.primary} translucent={false}
/> backgroundColor={colors.primary}
</SafeAreaProvider> />
</SafeAreaProvider>
</ErrorBoundary>
); );
} }
+2 -1
View File
@@ -35,7 +35,8 @@
"resizeMode": "contain" "resizeMode": "contain"
} }
], ],
"expo-font" "expo-font",
"@react-native-community/datetimepicker"
], ],
"extra": { "extra": {
"eas": { "eas": {
+24
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "9.1.0",
"@react-navigation/bottom-tabs": "^7.18.16", "@react-navigation/bottom-tabs": "^7.18.16",
"@react-navigation/native": "^7.3.16", "@react-navigation/native": "^7.3.16",
"@react-navigation/native-stack": "^7.18.8", "@react-navigation/native-stack": "^7.18.8",
@@ -1529,6 +1530,29 @@
"react-native": "^0.0.0-0 || >=0.65 <1.0" "react-native": "^0.0.0-0 || >=0.65 <1.0"
} }
}, },
"node_modules/@react-native-community/datetimepicker": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-9.1.0.tgz",
"integrity": "sha512-eadbnk+I2vxvW30iTAsm/qlCnMMAadkifIMYNEB2lzhxN/SvlKc7S2V4k5DyrwjdCbqdcMk3t9K6fnUMcAV34w==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": ">=52.0.0",
"react": "*",
"react-native": "*",
"react-native-windows": "*"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
},
"react-native-windows": {
"optional": true
}
}
},
"node_modules/@react-native/assets-registry": { "node_modules/@react-native/assets-registry": {
"version": "0.86.2", "version": "0.86.2",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.2.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.2.tgz",
+1
View File
@@ -5,6 +5,7 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "9.1.0",
"@react-navigation/bottom-tabs": "^7.18.16", "@react-navigation/bottom-tabs": "^7.18.16",
"@react-navigation/native": "^7.3.16", "@react-navigation/native": "^7.3.16",
"@react-navigation/native-stack": "^7.18.8", "@react-navigation/native-stack": "^7.18.8",
-1
View File
@@ -1,4 +1,3 @@
import { Ionicons } from '@expo/vector-icons';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { import {
Animated, Animated,
+29 -8
View File
@@ -184,17 +184,25 @@ export async function updateTransaction(
}); });
} }
export async function getTransactions(limit = 50): Promise<TransactionWithCategory[]> { export async function getTransactions(
limit = 50,
month: string | null = null
): Promise<TransactionWithCategory[]> {
const db = getDb(); const db = getDb();
const monthClause = month
? `WHERE strftime('%Y-%m', t.occurred_at, 'localtime') = ?`
: '';
const params = month ? [month, limit] : [limit];
return db.getAllAsync<TransactionWithCategory>( return db.getAllAsync<TransactionWithCategory>(
`SELECT t.id, t.type, t.amount, t.category_id AS categoryId, t.note, `SELECT t.id, t.type, t.amount, t.category_id AS categoryId, t.note,
t.occurred_at AS occurredAt, t.created_at AS createdAt, t.occurred_at AS occurredAt, t.created_at AS createdAt,
c.name AS categoryName, c.icon AS categoryIcon c.name AS categoryName, c.icon AS categoryIcon
FROM transactions t FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN categories c ON c.id = t.category_id
${monthClause}
ORDER BY t.occurred_at DESC, t.id DESC ORDER BY t.occurred_at DESC, t.id DESC
LIMIT ?`, LIMIT ?`,
[limit] params
); );
} }
@@ -256,14 +264,21 @@ export async function getTopExpenseCategory(
return row ?? null; return row ?? null;
} }
export async function getMonthSummary(): Promise<{ income: number; expense: number }> { export async function getMonthSummary(
month: string | null = null
): Promise<{ income: number; expense: number }> {
const db = getDb(); const db = getDb();
const monthClause = month
? `strftime('%Y-%m', occurred_at, 'localtime') = ?`
: `strftime('%Y-%m', occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')`;
const params = month ? [month] : [];
const row = await db.getFirstAsync<{ income: number; expense: number }>( const row = await db.getFirstAsync<{ income: number; expense: number }>(
`SELECT `SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount END), 0) AS income, COALESCE(SUM(CASE WHEN type = 'income' THEN amount END), 0) AS income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount END), 0) AS expense COALESCE(SUM(CASE WHEN type = 'expense' THEN amount END), 0) AS expense
FROM transactions FROM transactions
WHERE strftime('%Y-%m', occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')` WHERE ${monthClause}`,
params
); );
return { income: row?.income ?? 0, expense: row?.expense ?? 0 }; return { income: row?.income ?? 0, expense: row?.expense ?? 0 };
} }
@@ -371,18 +386,24 @@ export async function deleteSavingsTarget(id: number): Promise<void> {
await db.runAsync('DELETE FROM savings_targets WHERE id = ?', [id]); await db.runAsync('DELETE FROM savings_targets WHERE id = ?', [id]);
} }
export async function getExpenseByCategory(): Promise< export async function getExpenseByCategory(
month: string | null = null
): Promise<
Array<{ categoryName: string; categoryIcon: string; total: number }> Array<{ categoryName: string; categoryIcon: string; total: number }>
> { > {
const db = getDb(); const db = getDb();
const monthClause = month
? `strftime('%Y-%m', t.occurred_at, 'localtime') = ?`
: `strftime('%Y-%m', t.occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')`;
const params = month ? [month] : [];
return db.getAllAsync( return db.getAllAsync(
`SELECT c.name AS categoryName, c.icon AS categoryIcon, SUM(t.amount) AS total `SELECT c.name AS categoryName, c.icon AS categoryIcon, SUM(t.amount) AS total
FROM transactions t FROM transactions t
JOIN categories c ON c.id = t.category_id JOIN categories c ON c.id = t.category_id
WHERE t.type = 'expense' WHERE t.type = 'expense' AND ${monthClause}
AND strftime('%Y-%m', t.occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')
GROUP BY c.id GROUP BY c.id
ORDER BY total DESC` ORDER BY total DESC`,
params
); );
} }
+52 -22
View File
@@ -1,7 +1,9 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Pressable, StyleSheet, View } from 'react-native'; import { Pressable, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors } from '../theme/colors'; import { colors } from '../theme/colors';
import { DashboardScreen } from '../screens/DashboardScreen'; import { DashboardScreen } from '../screens/DashboardScreen';
import { HistoryScreen } from '../screens/HistoryScreen'; import { HistoryScreen } from '../screens/HistoryScreen';
@@ -48,11 +50,13 @@ const tabIcon =
(focusedName: IoniconName, unfocusedName: IoniconName) => (focusedName: IoniconName, unfocusedName: IoniconName) =>
({ color, size, focused }: { color: string; size: number; focused: boolean }) => ({ color, size, focused }: { color: string; size: number; focused: boolean }) =>
( (
<Ionicons <View style={[styles.tabIconWrap, focused && styles.tabIconWrapActive]}>
name={focused ? focusedName : unfocusedName} <Ionicons
size={size} name={focused ? focusedName : unfocusedName}
color={color} size={size}
/> color={color}
/>
</View>
); );
/** Tombol "+" melingkar di tengah tab bar (FAB) */ /** Tombol "+" melingkar di tengah tab bar (FAB) */
@@ -70,21 +74,30 @@ function FabButton({
style={({ pressed }) => [styles.fabWrapper, pressed && styles.fabPressed]} style={({ pressed }) => [styles.fabWrapper, pressed && styles.fabPressed]}
hitSlop={8} hitSlop={8}
> >
<View style={[styles.fab, focused && styles.fabActive]}> <LinearGradient
colors={[colors.primary, colors.primaryDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.fab, focused && styles.fabActive]}
>
<Ionicons name="add" size={32} color={colors.white} /> <Ionicons name="add" size={32} color={colors.white} />
</View> </LinearGradient>
</Pressable> </Pressable>
); );
} }
function MainTabs() { function MainTabs() {
const insets = useSafeAreaInsets();
return ( return (
<Tab.Navigator <Tab.Navigator
screenOptions={{ screenOptions={{
tabBarActiveTintColor: colors.primary, tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textMuted, tabBarInactiveTintColor: colors.textMuted,
tabBarLabelStyle: styles.tabLabel, tabBarLabelStyle: styles.tabLabel,
tabBarStyle: styles.tabBar, tabBarStyle: [
styles.tabBar,
{ bottom: insets.bottom > 0 ? insets.bottom + 10 : 14 },
],
headerTitleStyle: { fontWeight: '700', color: colors.text }, headerTitleStyle: { fontWeight: '700', color: colors.text },
headerStyle: { backgroundColor: colors.background }, headerStyle: { backgroundColor: colors.background },
headerShadowVisible: false, headerShadowVisible: false,
@@ -200,40 +213,57 @@ export function RootNavigator({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
tabBar: { tabBar: {
position: 'absolute',
left: 14,
right: 14,
height: 64,
borderRadius: 26,
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderTopColor: colors.border, borderTopWidth: 0,
borderTopWidth: 1, paddingTop: 6,
height: 62,
paddingTop: 4,
paddingBottom: 6, paddingBottom: 6,
shadowColor: '#0F172A',
shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 6,
}, },
tabLabel: { tabLabel: {
fontSize: 10, fontSize: 10,
fontWeight: '600', fontWeight: '600',
}, },
tabIconWrap: {
width: 40,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center',
},
tabIconWrapActive: {
backgroundColor: colors.primarySoft,
},
fabWrapper: { fabWrapper: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
top: -24, top: -26,
}, },
fabPressed: { opacity: 0.85 }, fabPressed: { opacity: 0.85 },
fab: { fab: {
width: 58, width: 60,
height: 58, height: 60,
borderRadius: 29, borderRadius: 30,
backgroundColor: colors.primary,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderWidth: 4, borderWidth: 4,
borderColor: colors.surface, borderColor: colors.surface,
shadowColor: colors.primaryDark, shadowColor: colors.primaryDark,
shadowOpacity: 0.35, shadowOpacity: 0.4,
shadowRadius: 10, shadowRadius: 12,
shadowOffset: { width: 0, height: 5 }, shadowOffset: { width: 0, height: 6 },
elevation: 8, elevation: 10,
}, },
fabActive: { fabActive: {
backgroundColor: colors.primaryDark, shadowOpacity: 0.55,
}, },
}); });
+46 -1
View File
@@ -1,4 +1,5 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useFocusEffect, useRoute } from '@react-navigation/native'; import { useFocusEffect, useRoute } from '@react-navigation/native';
import type { RouteProp } from '@react-navigation/native'; import type { RouteProp } from '@react-navigation/native';
@@ -41,6 +42,8 @@ export function AddTransactionScreen() {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null); const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [occurredAt, setOccurredAt] = useState(new Date());
const [showDate, setShowDate] = useState(false);
const [percent, setPercent] = useState(10); const [percent, setPercent] = useState(10);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -85,6 +88,7 @@ export function AddTransactionScreen() {
amount, amount,
categoryId: selectedCategoryId, categoryId: selectedCategoryId,
note: note.trim(), note: note.trim(),
occurredAt,
}); });
if (type === 'income') { if (type === 'income') {
Alert.alert( Alert.alert(
@@ -182,6 +186,33 @@ export function AddTransactionScreen() {
</View> </View>
)} )}
{/* Tanggal */}
<Text style={styles.sectionTitle}>Tanggal</Text>
<Pressable style={styles.dateRow} onPress={() => setShowDate(true)}>
<Ionicons name="calendar" size={18} color={colors.primaryDark} />
<Text style={styles.dateRowText}>
{occurredAt.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
<Ionicons name="chevron-down" size={16} color={colors.textMuted} />
</Pressable>
{showDate && (
<DateTimePicker
value={occurredAt}
mode="date"
maximumDate={new Date()}
onValueChange={(e, d) => {
setShowDate(false);
if (d) setOccurredAt(d);
}}
onDismiss={() => setShowDate(false)}
/>
)}
{/* Kategori */} {/* Kategori */}
<Text style={styles.sectionTitle}>Kategori</Text> <Text style={styles.sectionTitle}>Kategori</Text>
<View style={styles.categoryGrid}> <View style={styles.categoryGrid}>
@@ -244,7 +275,7 @@ export function AddTransactionScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.background }, flex: { flex: 1, backgroundColor: colors.background },
container: { flex: 1 }, container: { flex: 1 },
content: { padding: 16, paddingBottom: 32 }, content: { padding: 16, paddingBottom: 110 },
toggleRow: { toggleRow: {
flexDirection: 'row', flexDirection: 'row',
backgroundColor: colors.surface, backgroundColor: colors.surface,
@@ -298,6 +329,19 @@ const styles = StyleSheet.create({
paddingVertical: 4, paddingVertical: 4,
}, },
amountPreview: { fontSize: 13, color: colors.textMuted }, amountPreview: { fontSize: 13, color: colors.textMuted },
dateRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 4,
},
dateRowText: { flex: 1, fontSize: 14, color: colors.text, fontWeight: '600' },
saveHintCard: { saveHintCard: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -337,6 +381,7 @@ const styles = StyleSheet.create({
noteInput: { paddingVertical: 10, fontSize: 14, color: colors.text }, noteInput: { paddingVertical: 10, fontSize: 14, color: colors.text },
saveBar: { saveBar: {
padding: 16, padding: 16,
paddingBottom: 84,
backgroundColor: colors.background, backgroundColor: colors.background,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: colors.border, borderTopColor: colors.border,
+1 -1
View File
@@ -13,7 +13,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { colors } from '../theme/colors'; import { colors } from '../theme/colors';
import { addCategory, getCategories, updateCategory } from '../db/repositories'; import { addCategory, updateCategory } from '../db/repositories';
import type { TransactionType } from '../types'; import type { TransactionType } from '../types';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
+20 -15
View File
@@ -75,7 +75,6 @@ export function DashboardScreen() {
}, [heartScale]); }, [heartScale]);
const [monthExpense, setMonthExpense] = useState(0); const [monthExpense, setMonthExpense] = useState(0);
const [monthIncome, setMonthIncome] = useState(0);
const [topCategory, setTopCategory] = useState<{ const [topCategory, setTopCategory] = useState<{
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
@@ -108,7 +107,6 @@ export function DashboardScreen() {
setRecent(r); setRecent(r);
setCompare(c); setCompare(c);
setMonthExpense(m.expense); setMonthExpense(m.expense);
setMonthIncome(m.income);
setTopCategory(top); setTopCategory(top);
setTopCategoryLast(topLast); setTopCategoryLast(topLast);
setUserName(un ?? ''); setUserName(un ?? '');
@@ -118,9 +116,7 @@ export function DashboardScreen() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let active = true; let active = true;
load().then(() => { load().catch(() => {});
if (!active) return;
});
return () => { return () => {
active = false; active = false;
}; };
@@ -166,7 +162,6 @@ export function DashboardScreen() {
const avgDailyExpense = dayOfMonth > 0 ? monthExpense / dayOfMonth : 0; const avgDailyExpense = dayOfMonth > 0 ? monthExpense / dayOfMonth : 0;
const coverageDays = const coverageDays =
avgDailyExpense > 0 ? Math.floor(balance / avgDailyExpense) : null; avgDailyExpense > 0 ? Math.floor(balance / avgDailyExpense) : null;
const coverageOk = coverageDays !== null && coverageDays >= remainingDays;
// Tip otomatis dari pola kategori // Tip otomatis dari pola kategori
let tip: string | null = null; let tip: string | null = null;
@@ -188,26 +183,36 @@ export function DashboardScreen() {
} }
const hasTodayActivity = today.income > 0 || today.expense > 0; const hasTodayActivity = today.income > 0 || today.expense > 0;
const monthNet = monthIncome - monthExpense;
// Nyawa keuangan (health bar) // Nyawa keuangan (health bar)
const hpPct = const hpPct =
avgDailyExpense > 0 balance <= 0
? 0
: remainingDays <= 0
? 100
: avgDailyExpense > 0
? Math.min(100, Math.round(((coverageDays ?? 0) / remainingDays) * 100)) ? Math.min(100, Math.round(((coverageDays ?? 0) / remainingDays) * 100))
: 100; : 100;
let hpStatus = 'SEHAT'; let hpStatus = 'SEHAT';
let hpColor = '#16A34A'; let hpColor = '#16A34A';
let hpBarColor = '#22C55E'; let hpBarColor = '#22C55E';
let hpMsg = let hpMsg = '';
avgDailyExpense > 0 if (balance <= 0) {
? `Nyawa aman — saldo cukup ${coverageDays} hari (sisa ${remainingDays}) 🛡️` hpStatus = 'KRITIS';
: 'Belum ada pengeluaran bulan ini — nyawa penuh! 😎'; hpColor = '#DC2626';
if (avgDailyExpense > 0 && hpPct < 40) { hpBarColor = '#EF4444';
hpMsg = 'Saldo kosong! Catat pemasukan sekarang 🚨';
} else if (avgDailyExpense > 0) {
hpMsg = `Nyawa aman — saldo cukup ${coverageDays} hari (sisa ${remainingDays}) 🛡️`;
} else {
hpMsg = 'Belum ada pengeluaran bulan ini — nyawa penuh! 😎';
}
if (balance > 0 && avgDailyExpense > 0 && hpPct < 40) {
hpStatus = 'KRITIS'; hpStatus = 'KRITIS';
hpColor = '#DC2626'; hpColor = '#DC2626';
hpBarColor = '#EF4444'; hpBarColor = '#EF4444';
hpMsg = `Saldo cukup ${coverageDays} dari ${remainingDays} hari — kurangi belanja! 🚨`; hpMsg = `Saldo cukup ${coverageDays} dari ${remainingDays} hari — kurangi belanja! 🚨`;
} else if (avgDailyExpense > 0 && hpPct < 75) { } else if (balance > 0 && avgDailyExpense > 0 && hpPct < 75) {
hpStatus = 'WASPADA'; hpStatus = 'WASPADA';
hpColor = '#D97706'; hpColor = '#D97706';
hpBarColor = '#F59E0B'; hpBarColor = '#F59E0B';
@@ -711,7 +716,7 @@ const styles = StyleSheet.create({
overflow: 'hidden', overflow: 'hidden',
}, },
container: { flex: 1 }, container: { flex: 1 },
content: { paddingBottom: 32 }, content: { paddingBottom: 110 },
header: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 8, marginBottom: 12 }, header: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 8, marginBottom: 12 },
headerRow: { headerRow: {
flexDirection: 'row', flexDirection: 'row',
+45
View File
@@ -1,4 +1,5 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useNavigation, useRoute } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native';
import type { RouteProp } from '@react-navigation/native'; import type { RouteProp } from '@react-navigation/native';
@@ -37,6 +38,8 @@ export function EditTransactionScreen() {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null); const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [occurredAt, setOccurredAt] = useState(new Date());
const [showDate, setShowDate] = useState(false);
const [percent, setPercent] = useState(10); const [percent, setPercent] = useState(10);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
@@ -53,6 +56,7 @@ export function EditTransactionScreen() {
setAmountText(String(tx.amount)); setAmountText(String(tx.amount));
setSelectedCategoryId(tx.categoryId); setSelectedCategoryId(tx.categoryId);
setNote(tx.note); setNote(tx.note);
setOccurredAt(new Date(tx.occurredAt));
const p = await getSettingNumber('auto_save_percent', 10); const p = await getSettingNumber('auto_save_percent', 10);
setPercent(p); setPercent(p);
const cats = await getCategories(tx.type); const cats = await getCategories(tx.type);
@@ -86,6 +90,7 @@ export function EditTransactionScreen() {
amount, amount,
categoryId: selectedCategoryId, categoryId: selectedCategoryId,
note: note.trim(), note: note.trim(),
occurredAt,
}); });
Alert.alert('Tersimpan ✅', 'Transaksi berhasil diperbarui.', [ Alert.alert('Tersimpan ✅', 'Transaksi berhasil diperbarui.', [
{ text: 'Oke', onPress: () => navigation.goBack() }, { text: 'Oke', onPress: () => navigation.goBack() },
@@ -176,6 +181,33 @@ export function EditTransactionScreen() {
</View> </View>
)} )}
{/* Tanggal */}
<Text style={styles.sectionTitle}>Tanggal</Text>
<Pressable style={styles.dateRow} onPress={() => setShowDate(true)}>
<Ionicons name="calendar" size={18} color={colors.primaryDark} />
<Text style={styles.dateRowText}>
{occurredAt.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
<Ionicons name="chevron-down" size={16} color={colors.textMuted} />
</Pressable>
{showDate && (
<DateTimePicker
value={occurredAt}
mode="date"
maximumDate={new Date()}
onValueChange={(e, d) => {
setShowDate(false);
if (d) setOccurredAt(d);
}}
onDismiss={() => setShowDate(false)}
/>
)}
{/* Kategori */} {/* Kategori */}
<Text style={styles.sectionTitle}>Kategori</Text> <Text style={styles.sectionTitle}>Kategori</Text>
<View style={styles.categoryGrid}> <View style={styles.categoryGrid}>
@@ -291,6 +323,19 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
paddingVertical: 4, paddingVertical: 4,
}, },
dateRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 4,
},
dateRowText: { flex: 1, fontSize: 14, color: colors.text, fontWeight: '600' },
saveHintCard: { saveHintCard: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
+98 -15
View File
@@ -3,7 +3,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { Alert, Image, Pressable, SectionList, StyleSheet, Text, View } from 'react-native'; import { Alert, Image, Modal, Pressable, ScrollView, SectionList, StyleSheet, Text, View } from 'react-native';
import Svg, { Circle, G } from 'react-native-svg'; import Svg, { Circle, G } from 'react-native-svg';
import { colors } from '../theme/colors'; import { colors } from '../theme/colors';
import { formatRupiah, formatRupiahShort } from '../utils/format'; import { formatRupiah, formatRupiahShort } from '../utils/format';
@@ -30,24 +30,37 @@ export function HistoryScreen() {
const [categoryData, setCategoryData] = useState< const [categoryData, setCategoryData] = useState<
Array<{ categoryName: string; categoryIcon: string; total: number }> Array<{ categoryName: string; categoryIcon: string; total: number }>
>([]); >([]);
const [monthKey, setMonthKey] = useState<string | null>(null);
const [monthModal, setMonthModal] = useState(false);
const monthOptions = useMemo(() => {
const now = new Date();
const out: Array<{ key: string; label: string }> = [];
for (let i = 0; i < 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
out.push({
key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`,
label: d.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' }),
});
}
return out;
}, []);
const load = useCallback(async () => { const load = useCallback(async () => {
const [txs, m, cats] = await Promise.all([ const [txs, m, cats] = await Promise.all([
getTransactions(500), getTransactions(500, monthKey),
getMonthSummary(), getMonthSummary(monthKey),
getExpenseByCategory(), getExpenseByCategory(monthKey),
]); ]);
setTransactions(txs); setTransactions(txs);
setMonth(m); setMonth(m);
setCategoryData(cats); setCategoryData(cats);
}, []); }, [monthKey]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let active = true; let active = true;
load().then(() => { load().catch(() => {});
if (!active) return;
});
return () => { return () => {
active = false; active = false;
}; };
@@ -72,10 +85,8 @@ export function HistoryScreen() {
return [...map.entries()].map(([title, data]) => ({ title, data })); return [...map.entries()].map(([title, data]) => ({ title, data }));
}, [transactions, filter]); }, [transactions, filter]);
const monthLabel = new Date().toLocaleDateString('id-ID', { const monthLabel =
month: 'long', monthOptions.find((o) => o.key === monthKey)?.label ?? monthOptions[0]?.label ?? '';
year: 'numeric',
});
const net = month.income - month.expense; const net = month.income - month.expense;
const handleDelete = (item: TransactionWithCategory) => { const handleDelete = (item: TransactionWithCategory) => {
@@ -128,10 +139,11 @@ export function HistoryScreen() {
/> />
<View style={styles.summaryTop}> <View style={styles.summaryTop}>
<Text style={styles.summaryLabel}>RINGKASAN BULAN INI</Text> <Text style={styles.summaryLabel}>RINGKASAN BULAN INI</Text>
<View style={styles.monthBadge}> <Pressable style={styles.monthBadge} onPress={() => setMonthModal(true)}>
<Ionicons name="calendar" size={12} color={colors.primaryDark} /> <Ionicons name="calendar" size={12} color={colors.primaryDark} />
<Text style={styles.monthBadgeText}>{monthLabel}</Text> <Text style={styles.monthBadgeText}>{monthLabel}</Text>
</View> <Ionicons name="chevron-down" size={12} color={colors.primaryDark} />
</Pressable>
</View> </View>
<View style={styles.summaryRow}> <View style={styles.summaryRow}>
<View style={styles.summaryItem}> <View style={styles.summaryItem}>
@@ -288,6 +300,47 @@ export function HistoryScreen() {
); );
}} }}
/> />
{/* Modal pilih bulan */}
<Modal
visible={monthModal}
transparent
animationType="slide"
onRequestClose={() => setMonthModal(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setMonthModal(false)}>
<Pressable style={styles.modalSheet} onPress={() => {}}>
<Text style={styles.modalTitle}>Pilih bulan</Text>
<ScrollView style={styles.modalScroll}>
{monthOptions.map((o) => {
const active = o.key === monthKey;
return (
<Pressable
key={o.key}
style={[styles.monthRow, active && styles.monthRowActive]}
onPress={() => {
setMonthKey(o.key);
setMonthModal(false);
}}
>
<Text
style={[
styles.monthRowText,
active && { color: colors.primaryDark, fontWeight: '700' },
]}
>
{o.label}
</Text>
{active && (
<Ionicons name="checkmark" size={18} color={colors.primary} />
)}
</Pressable>
);
})}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
</View> </View>
); );
} }
@@ -351,7 +404,7 @@ function DonutChart({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background }, container: { flex: 1, backgroundColor: colors.background },
listContent: { paddingBottom: 32 }, listContent: { paddingBottom: 110 },
card: { card: {
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderRadius: 16, borderRadius: 16,
@@ -420,6 +473,36 @@ const styles = StyleSheet.create({
paddingVertical: 4, paddingVertical: 4,
}, },
monthBadgeText: { fontSize: 11, fontWeight: '700', color: colors.primaryDark }, monthBadgeText: { fontSize: 11, fontWeight: '700', color: colors.primaryDark },
modalBackdrop: {
flex: 1,
backgroundColor: 'transparent',
justifyContent: 'flex-end',
},
modalSheet: {
backgroundColor: colors.surface,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 20,
paddingBottom: 36,
},
modalTitle: {
fontSize: 16,
fontWeight: '800',
color: colors.text,
marginBottom: 12,
textAlign: 'center',
},
modalScroll: { maxHeight: 420 },
monthRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 13,
paddingHorizontal: 12,
borderRadius: 12,
},
monthRowActive: { backgroundColor: colors.primarySoft },
monthRowText: { fontSize: 15, color: colors.text },
summaryRow: { flexDirection: 'row', gap: 12, marginTop: 14 }, summaryRow: { flexDirection: 'row', gap: 12, marginTop: 14 },
summaryItem: { flex: 1 }, summaryItem: { flex: 1 },
summaryItemLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 3 }, summaryItemLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 3 },
+9 -4
View File
@@ -27,10 +27,15 @@ export function OnboardingScreen() {
const [gender, setGender] = useState<'male' | 'female' | null>(null); const [gender, setGender] = useState<'male' | 'female' | null>(null);
const handleFinish = async () => { const handleFinish = async () => {
await setSetting('user_name', name.trim()); try {
await setSetting('user_gender', gender ?? 'male'); await setSetting('user_name', name.trim());
await setSetting('onboarded', '1'); await setSetting('user_gender', gender ?? 'male');
navigation.replace('Main'); await setSetting('onboarded', '1');
navigation.replace('Main');
} catch {
// tetap lanjut walau gagal simpan
navigation.replace('Main');
}
}; };
return ( return (
+1 -1
View File
@@ -15,7 +15,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { colors } from '../theme/colors'; import { colors } from '../theme/colors';
import { formatRibuan, formatRupiah } from '../utils/format'; import { formatRibuan } from '../utils/format';
import { getCategoryIcon3d } from '../utils/categoryIcons3d'; import { getCategoryIcon3d } from '../utils/categoryIcons3d';
import { addRecurring, getCategories } from '../db/repositories'; import { addRecurring, getCategories } from '../db/repositories';
import type { Category, TransactionType } from '../types'; import type { Category, TransactionType } from '../types';
+6 -5
View File
@@ -69,9 +69,7 @@ export function SavingsScreen() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let active = true; let active = true;
load().then(() => { load().catch(() => {});
if (!active) return;
});
return () => { return () => {
active = false; active = false;
}; };
@@ -176,7 +174,10 @@ export function SavingsScreen() {
</View> </View>
) : ( ) : (
targets.map((target) => { targets.map((target) => {
const pct = Math.min(100, Math.round((total / target.targetAmount) * 100)); const pct =
target.targetAmount > 0
? Math.min(100, Math.round((total / target.targetAmount) * 100))
: 0;
const remaining = Math.max(0, target.targetAmount - total); const remaining = Math.max(0, target.targetAmount - total);
const now = new Date(); const now = new Date();
const dayOfMonth = now.getDate(); const dayOfMonth = now.getDate();
@@ -330,7 +331,7 @@ function ComparisonCard({ current, previous }: { current: number; previous: numb
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background }, container: { flex: 1, backgroundColor: colors.background },
content: { padding: 16, paddingBottom: 32 }, content: { padding: 16, paddingBottom: 110 },
card: { card: {
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderRadius: 16, borderRadius: 16,
+4 -6
View File
@@ -102,15 +102,13 @@ export function SettingsScreen() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let active = true; let active = true;
loadSettings().then(() => { loadSettings().catch(() => {});
if (!active) return;
});
getRecurring().then((r) => { getRecurring().then((r) => {
if (active) setRecurring(r); if (active) setRecurring(r);
}); }).catch(() => {});
getCategories().then((c) => { getCategories().then((c) => {
if (active) setCategories(c); if (active) setCategories(c);
}); }).catch(() => {});
return () => { return () => {
active = false; active = false;
}; };
@@ -595,7 +593,7 @@ export function SettingsScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background }, container: { flex: 1, backgroundColor: colors.background },
content: { padding: 16, paddingBottom: 32 }, content: { padding: 16, paddingBottom: 110 },
sectionTitle: { sectionTitle: {
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '700',
+1 -1
View File
@@ -12,7 +12,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { colors } from '../theme/colors'; import { colors } from '../theme/colors';
import { formatRibuan, formatRupiah } from '../utils/format'; import { formatRibuan } from '../utils/format';
import { addSavingsTarget } from '../db/repositories'; import { addSavingsTarget } from '../db/repositories';
const EMOJIS = ['🎯', '💼', '🏠', '✈️', '🚗', '💍', '📱', '💻', '🎮', '🐷', '🛡️', '🎁']; const EMOJIS = ['🎯', '💼', '🏠', '✈️', '🚗', '💍', '📱', '💻', '🎮', '🐷', '🛡️', '🎁'];