Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb22c1394a | ||
|
|
639f3a4ef6 | ||
|
|
d213fd35b6 | ||
|
|
983a545e2c | ||
|
|
c601a688e0 | ||
|
|
3ad1c71423 | ||
|
|
5d301c0b03 | ||
|
|
a627d5bccc | ||
|
|
def45e9e5b | ||
|
|
35e2f5a0df |
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
"resizeMode": "contain"
|
"resizeMode": "contain"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-font"
|
"expo-font",
|
||||||
|
"@react-native-community/datetimepicker"
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"eas": {
|
"eas": {
|
||||||
|
|||||||
Generated
+24
@@ -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",
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 = ['🎯', '💼', '🏠', '✈️', '🚗', '💍', '📱', '💻', '🎮', '🐷', '🛡️', '🎁'];
|
||||||
|
|||||||
Reference in New Issue
Block a user