Kantongku: onboarding personalisasi (nama + gender avatar 3D) + splash mewah + greeting dinamis

This commit is contained in:
Bro
2026-08-16 16:24:29 +07:00
parent 723c1995e2
commit 450f3f622a
10 changed files with 385 additions and 18 deletions
+9 -2
View File
@@ -1,15 +1,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } 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 { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NavigationContainer } from '@react-navigation/native'; import { NavigationContainer } from '@react-navigation/native';
import { initDatabase } from './src/db/database'; import { initDatabase } from './src/db/database';
import { checkRecurringDue } from './src/db/repositories'; import { checkRecurringDue, getSetting } from './src/db/repositories';
import { RootNavigator } from './src/navigation'; import { RootNavigator } from './src/navigation';
import { colors } from './src/theme/colors'; import { colors } from './src/theme/colors';
SplashScreen.preventAutoHideAsync().catch(() => {});
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);
const [initialRoute, setInitialRoute] = useState<'Main' | 'Onboarding'>('Onboarding');
useEffect(() => { useEffect(() => {
initDatabase() initDatabase()
@@ -20,7 +24,10 @@ export default function App() {
} catch { } catch {
// jangan sampai mengganggu startup // jangan sampai mengganggu startup
} }
const onboarded = await getSetting('onboarded');
setInitialRoute(onboarded === '1' ? 'Main' : 'Onboarding');
setReady(true); setReady(true);
SplashScreen.hideAsync().catch(() => {});
}) })
.catch((e) => setError(String(e))); .catch((e) => setError(String(e)));
}, []); }, []);
@@ -46,7 +53,7 @@ export default function App() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<NavigationContainer> <NavigationContainer>
<RootNavigator /> <RootNavigator initialRoute={initialRoute} />
</NavigationContainer> </NavigationContainer>
<StatusBar <StatusBar
barStyle="light-content" barStyle="light-content"
+10 -1
View File
@@ -24,7 +24,16 @@
}, },
"plugins": [ "plugins": [
"expo-sqlite", "expo-sqlite",
"expo-sharing" "expo-sharing",
[
"expo-splash-screen",
{
"backgroundColor": "#0D9488",
"image": "./assets/splash-logo.png",
"imageWidth": 220,
"resizeMode": "contain"
}
]
] ]
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+15
View File
@@ -18,6 +18,7 @@
"expo-file-system": "~57.0.4", "expo-file-system": "~57.0.4",
"expo-linear-gradient": "~57.0.1", "expo-linear-gradient": "~57.0.1",
"expo-sharing": "~57.0.12", "expo-sharing": "~57.0.12",
"expo-splash-screen": "~57.0.6",
"expo-sqlite": "~57.0.1", "expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"react": "19.2.3", "react": "19.2.3",
@@ -3064,6 +3065,20 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-splash-screen": {
"version": "57.0.6",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.6.tgz",
"integrity": "sha512-FY0E7hMyXVAsNh18yGzIjdooPXdjafalEb6mk2EcqNPjXspHuu+pVEhUscEunw4wF8F/w6MOpT7UfOwHVWez9g==",
"license": "MIT",
"dependencies": {
"@expo/config-plugins": "~57.0.7",
"@expo/image-utils": "^0.11.4",
"xml2js": "0.6.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-sqlite": { "node_modules/expo-sqlite": {
"version": "57.0.1", "version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.1.tgz", "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.1.tgz",
+1
View File
@@ -13,6 +13,7 @@
"expo-file-system": "~57.0.4", "expo-file-system": "~57.0.4",
"expo-linear-gradient": "~57.0.1", "expo-linear-gradient": "~57.0.1",
"expo-sharing": "~57.0.12", "expo-sharing": "~57.0.12",
"expo-splash-screen": "~57.0.6",
"expo-sqlite": "~57.0.1", "expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"react": "19.2.3", "react": "19.2.3",
+12 -2
View File
@@ -10,6 +10,7 @@ import { EditTransactionScreen } from '../screens/EditTransactionScreen';
import { RecurringFormScreen } from '../screens/RecurringFormScreen'; import { RecurringFormScreen } from '../screens/RecurringFormScreen';
import { TargetFormScreen } from '../screens/TargetFormScreen'; import { TargetFormScreen } from '../screens/TargetFormScreen';
import { CategoryFormScreen } from '../screens/CategoryFormScreen'; import { CategoryFormScreen } from '../screens/CategoryFormScreen';
import { OnboardingScreen } from '../screens/OnboardingScreen';
import { SavingsScreen } from '../screens/SavingsScreen'; import { SavingsScreen } from '../screens/SavingsScreen';
import { SettingsScreen } from '../screens/SettingsScreen'; import { SettingsScreen } from '../screens/SettingsScreen';
import type { TransactionType } from '../types'; import type { TransactionType } from '../types';
@@ -24,6 +25,7 @@ export type RootTabParamList = {
export type RootStackParamList = { export type RootStackParamList = {
Main: undefined; Main: undefined;
Onboarding: undefined;
EditTransaksi: { transactionId: number }; EditTransaksi: { transactionId: number };
RecurringForm: undefined; RecurringForm: undefined;
TargetForm: undefined; TargetForm: undefined;
@@ -132,10 +134,18 @@ function MainTabs() {
); );
} }
export function RootNavigator() { export function RootNavigator({
initialRoute = 'Main',
}: {
initialRoute?: 'Main' | 'Onboarding';
}) {
return ( return (
<Stack.Navigator screenOptions={{ headerShown: false }}> <Stack.Navigator
initialRouteName={initialRoute}
screenOptions={{ headerShown: false }}
>
<Stack.Screen name="Main" component={MainTabs} /> <Stack.Screen name="Main" component={MainTabs} />
<Stack.Screen name="Onboarding" component={OnboardingScreen} />
<Stack.Screen <Stack.Screen
name="EditTransaksi" name="EditTransaksi"
component={EditTransactionScreen} component={EditTransactionScreen}
+25 -13
View File
@@ -21,6 +21,7 @@ import {
getSavingsMonthComparison, getSavingsMonthComparison,
getSavingsStreak, getSavingsStreak,
getSavingsTotal, getSavingsTotal,
getSetting,
getTodaySummary, getTodaySummary,
getTopExpenseCategory, getTopExpenseCategory,
getTransactions, getTransactions,
@@ -47,6 +48,8 @@ export function DashboardScreen() {
const [recent, setRecent] = useState<TransactionWithCategory[]>([]); const [recent, setRecent] = useState<TransactionWithCategory[]>([]);
const [compare, setCompare] = useState({ current: 0, previous: 0 }); const [compare, setCompare] = useState({ current: 0, previous: 0 });
const [hidden, setHidden] = useState(true); const [hidden, setHidden] = useState(true);
const [userName, setUserName] = useState('');
const [userGender, setUserGender] = useState('');
const heartScale = useRef(new Animated.Value(1)).current; const heartScale = useRef(new Animated.Value(1)).current;
const hpAnim = useRef(new Animated.Value(0)).current; const hpAnim = useRef(new Animated.Value(0)).current;
const shimmerX = useRef(new Animated.Value(-140)).current; const shimmerX = useRef(new Animated.Value(-140)).current;
@@ -85,7 +88,7 @@ export function DashboardScreen() {
} | null>(null); } | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
const [b, s, st, t, r, c, m, top, topLast] = await Promise.all([ const [b, s, st, t, r, c, m, top, topLast, un, ug] = await Promise.all([
getBalance(), getBalance(),
getSavingsTotal(), getSavingsTotal(),
getSavingsStreak(), getSavingsStreak(),
@@ -95,6 +98,8 @@ export function DashboardScreen() {
getMonthSummary(), getMonthSummary(),
getTopExpenseCategory(), getTopExpenseCategory(),
getTopExpenseCategory('-1 month'), getTopExpenseCategory('-1 month'),
getSetting('user_name'),
getSetting('user_gender'),
]); ]);
setBalance(b); setBalance(b);
setSavings(s); setSavings(s);
@@ -106,6 +111,8 @@ export function DashboardScreen() {
setMonthIncome(m.income); setMonthIncome(m.income);
setTopCategory(top); setTopCategory(top);
setTopCategoryLast(topLast); setTopCategoryLast(topLast);
setUserName(un ?? '');
setUserGender(ug ?? '');
}, []); }, []);
useFocusEffect( useFocusEffect(
@@ -267,12 +274,22 @@ export function DashboardScreen() {
resizeMode="contain" resizeMode="contain"
/> />
<View style={styles.headerText}> <View style={styles.headerText}>
<Text style={styles.greeting}>Halo, Mas 👋</Text> <Text style={styles.greeting}>Halo, {userName || 'Mas'} 👋</Text>
<Text style={styles.date}>{todayLabel()}</Text> <Text style={styles.date}>{todayLabel()}</Text>
</View> </View>
<View style={styles.avatarCircle}> {userGender === 'female' ? (
<Text style={styles.avatarText}>N</Text> <Image
</View> source={require('../../assets/avatar-female.png')}
style={styles.avatarImg}
resizeMode="contain"
/>
) : (
<Image
source={require('../../assets/avatar-male.png')}
style={styles.avatarImg}
resizeMode="contain"
/>
)}
</View> </View>
<View style={styles.quoteBubble}> <View style={styles.quoteBubble}>
<Ionicons name="leaf" size={14} color={colors.primaryDark} /> <Ionicons name="leaf" size={14} color={colors.primaryDark} />
@@ -701,15 +718,10 @@ const styles = StyleSheet.create({
headerText: { flex: 1 }, headerText: { flex: 1 },
greeting: { fontSize: 22, fontWeight: '800', color: colors.text }, greeting: { fontSize: 22, fontWeight: '800', color: colors.text },
date: { fontSize: 13, color: colors.textMuted, marginTop: 2 }, date: { fontSize: 13, color: colors.textMuted, marginTop: 2 },
avatarCircle: { avatarImg: {
width: 40, width: 46,
height: 40, height: 52,
borderRadius: 20,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
}, },
avatarText: { color: colors.white, fontWeight: '800', fontSize: 16 },
quoteBubble: { quoteBubble: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
+313
View File
@@ -0,0 +1,313 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useState } from 'react';
import {
Image,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../theme/colors';
import { setSetting } from '../db/repositories';
import type { RootStackParamList } from '../navigation';
export function OnboardingScreen() {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [step, setStep] = useState<1 | 2>(1);
const [name, setName] = useState('');
const [gender, setGender] = useState<'male' | 'female' | null>(null);
const handleFinish = async () => {
await setSetting('user_name', name.trim());
await setSetting('user_gender', gender ?? 'male');
await setSetting('onboarded', '1');
navigation.replace('Main');
};
return (
<SafeAreaView style={styles.safe}>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.content}>
{/* Indikator langkah */}
<View style={styles.steps}>
<View style={[styles.stepDot, step === 1 && styles.stepDotActive]} />
<View style={[styles.stepDot, step === 2 && styles.stepDotActive]} />
</View>
{step === 1 ? (
<>
<LinearGradient
colors={[colors.primary, colors.primaryDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.hero}
>
<View style={styles.ornCircleLarge} />
<View style={styles.ornCircleSmall} />
<Image
source={require('../../assets/wallet-3d-teal-logo.png')}
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.heroTitle}>Halo kak! 👋</Text>
<Text style={styles.heroSub}>
Selamat datang di Kantongku — asisten keuangan pribadimu.
</Text>
</LinearGradient>
<Text style={styles.question}>Panggilan Kakak siapa?</Text>
<TextInput
style={[styles.card, styles.input]}
value={name}
onChangeText={setName}
placeholder="Ketik nama panggilan..."
placeholderTextColor={colors.textMuted}
autoFocus
returnKeyType="done"
onSubmitEditing={() => name.trim() && setStep(2)}
/>
<Pressable
style={({ pressed }) => [
styles.nextBtn,
!name.trim() && styles.nextBtnDisabled,
pressed && styles.pressed,
]}
disabled={!name.trim()}
onPress={() => setStep(2)}
>
<Text style={styles.nextBtnText}>Lanjut</Text>
<Ionicons name="arrow-forward" size={18} color={colors.white} />
</Pressable>
</>
) : (
<>
<LinearGradient
colors={[colors.primary, colors.primaryDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.hero}
>
<View style={styles.ornCircleLarge} />
<View style={styles.ornCircleSmall} />
<Text style={styles.heroTitle2}>
Kak {name.trim()}, Cowok atau Cewek nih? 🤔
</Text>
<Text style={styles.heroSub}>
Pilih avatar yang paling mirip kamu — biar Kantongku makin
personal.
</Text>
</LinearGradient>
<View style={styles.genderRow}>
<Pressable
style={[
styles.genderCard,
gender === 'male' && styles.genderCardActive,
]}
onPress={() => setGender('male')}
>
<Image
source={require('../../assets/avatar-male.png')}
style={styles.avatar}
resizeMode="contain"
/>
<Text style={styles.genderLabel}>Cowok</Text>
{gender === 'male' && (
<View style={styles.checkBadge}>
<Ionicons name="checkmark" size={14} color={colors.white} />
</View>
)}
</Pressable>
<Pressable
style={[
styles.genderCard,
gender === 'female' && styles.genderCardActive,
]}
onPress={() => setGender('female')}
>
<Image
source={require('../../assets/avatar-female.png')}
style={styles.avatar}
resizeMode="contain"
/>
<Text style={styles.genderLabel}>Cewek</Text>
{gender === 'female' && (
<View style={styles.checkBadge}>
<Ionicons name="checkmark" size={14} color={colors.white} />
</View>
)}
</Pressable>
</View>
<Pressable
style={({ pressed }) => [
styles.nextBtn,
!gender && styles.nextBtnDisabled,
pressed && styles.pressed,
]}
disabled={!gender}
onPress={handleFinish}
>
<Text style={styles.nextBtnText}>🚀 Mulai Pakai Kantongku</Text>
</Pressable>
</>
)}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.background },
flex: { flex: 1 },
content: { padding: 16, paddingBottom: 32, flexGrow: 1 },
steps: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
marginVertical: 16,
},
stepDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.border,
},
stepDotActive: {
backgroundColor: colors.primary,
width: 24,
},
hero: {
borderRadius: 24,
padding: 24,
overflow: 'hidden',
alignItems: 'center',
shadowColor: colors.primaryDark,
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
ornCircleLarge: {
position: 'absolute',
right: -40,
top: -50,
width: 160,
height: 160,
borderRadius: 80,
backgroundColor: 'rgba(255,255,255,0.07)',
},
ornCircleSmall: {
position: 'absolute',
left: -30,
bottom: -40,
width: 110,
height: 110,
borderRadius: 55,
backgroundColor: 'rgba(255,255,255,0.06)',
},
logo: { width: 76, height: 76, marginBottom: 10 },
heroTitle: {
fontSize: 24,
fontWeight: '800',
color: colors.white,
},
heroTitle2: {
fontSize: 20,
fontWeight: '800',
color: colors.white,
textAlign: 'center',
lineHeight: 27,
},
heroSub: {
fontSize: 13,
color: 'rgba(255,255,255,0.9)',
textAlign: 'center',
lineHeight: 19,
marginTop: 8,
},
question: {
fontSize: 17,
fontWeight: '700',
color: colors.text,
marginTop: 24,
marginBottom: 10,
},
card: {
backgroundColor: colors.surface,
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
},
input: {
paddingVertical: 14,
fontSize: 16,
color: colors.text,
marginBottom: 16,
},
nextBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
backgroundColor: colors.primary,
borderRadius: 14,
paddingVertical: 15,
marginTop: 8,
shadowColor: colors.primaryDark,
shadowOpacity: 0.25,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
nextBtnDisabled: { opacity: 0.4, elevation: 0, shadowOpacity: 0 },
nextBtnText: { color: colors.white, fontWeight: '800', fontSize: 16 },
genderRow: { flexDirection: 'row', gap: 12, marginTop: 24, marginBottom: 20 },
genderCard: {
flex: 1,
alignItems: 'center',
backgroundColor: colors.surface,
borderWidth: 2,
borderColor: colors.border,
borderRadius: 20,
paddingVertical: 20,
position: 'relative',
},
genderCardActive: {
borderColor: colors.primary,
backgroundColor: colors.primarySoft,
},
avatar: { width: 110, height: 130 },
genderLabel: {
fontSize: 16,
fontWeight: '800',
color: colors.text,
marginTop: 10,
},
checkBadge: {
position: 'absolute',
top: 10,
right: 10,
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
pressed: { opacity: 0.85 },
});