feat: Introduce financial goal and asset management with SQLite and NativeWind integration, new tab screens, and updated dependencies.

main
João Miranda 2025-11-19 23:01:03 +00:00
parent 60360e8eb9
commit e002d0d1fd
21 changed files with 11538 additions and 98 deletions

1
app.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="nativewind/types" />

View File

@ -1,11 +1,11 @@
import React from 'react';
import FontAwesome from '@expo/vector-icons/FontAwesome'; import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Link, Tabs } from 'expo-router'; import { Link, Tabs } from 'expo-router';
import React from 'react';
import { Pressable } from 'react-native'; import { Pressable } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';
import { useClientOnlyValue } from '@/components/useClientOnlyValue'; import { useClientOnlyValue } from '@/components/useClientOnlyValue';
import { useColorScheme } from '@/components/useColorScheme';
import Colors from '@/constants/Colors';
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/ // You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
function TabBarIcon(props: { function TabBarIcon(props: {
@ -29,14 +29,14 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: 'Tab One', title: 'Dashboard',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />, tabBarIcon: ({ color }) => <TabBarIcon name="dashboard" color={color} />,
headerRight: () => ( headerRight: () => (
<Link href="/modal" asChild> <Link href="/modal" asChild>
<Pressable> <Pressable>
{({ pressed }) => ( {({ pressed }) => (
<FontAwesome <FontAwesome
name="info-circle" name="plus-circle"
size={25} size={25}
color={Colors[colorScheme ?? 'light'].text} color={Colors[colorScheme ?? 'light'].text}
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }} style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
@ -48,10 +48,45 @@ export default function TabLayout() {
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="two" name="investments"
options={{ options={{
title: 'Tab Two', title: 'Investments',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />, tabBarIcon: ({ color }) => <TabBarIcon name="line-chart" color={color} />,
headerRight: () => (
<Link href="/add-asset" asChild>
<Pressable>
{({ pressed }) => (
<FontAwesome
name="plus-circle"
size={25}
color={Colors[colorScheme ?? 'light'].text}
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
),
}}
/>
<Tabs.Screen
name="goals"
options={{
title: 'Goals',
tabBarIcon: ({ color }) => <TabBarIcon name="flag" color={color} />,
headerRight: () => (
<Link href="/add-goal" asChild>
<Pressable>
{({ pressed }) => (
<FontAwesome
name="plus-circle"
size={25}
color={Colors[colorScheme ?? 'light'].text}
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
),
}} }}
/> />
</Tabs> </Tabs>

81
app/(tabs)/goals.tsx Normal file
View File

@ -0,0 +1,81 @@
import { useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
import { ScrollView, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { getDB } from '../../db';
import { Goal } from '../../types';
export default function GoalsScreen() {
const insets = useSafeAreaInsets();
const [goals, setGoals] = useState<Goal[]>([]);
const fetchData = useCallback(async () => {
try {
const db = getDB();
const result = await db.getAllAsync<Goal>('SELECT * FROM goals ORDER BY deadline ASC');
setGoals(result);
} catch (error) {
console.error('Error fetching goals', error);
}
}, []);
useFocusEffect(
useCallback(() => {
fetchData();
}, [fetchData])
);
const calculateProgress = (current: number, target: number) => {
if (target === 0) return 0;
return Math.min((current / target) * 100, 100);
};
return (
<View className="flex-1 bg-gray-100 dark:bg-gray-900" style={{ paddingTop: insets.top }}>
<ScrollView className="flex-1 px-6">
<View className="py-6">
<Text className="text-3xl font-bold text-gray-900 dark:text-white">Financial Goals</Text>
<Text className="text-gray-500 mt-1">Stay disciplined and reach your targets.</Text>
</View>
{goals.length === 0 ? (
<View className="items-center py-10">
<Text className="text-gray-400">No goals set yet.</Text>
</View>
) : (
goals.map((item) => {
const progress = calculateProgress(item.current_amount, item.target_amount);
return (
<View key={item.id} className="bg-white dark:bg-gray-800 p-6 mb-4 rounded-2xl shadow-sm">
<View className="flex-row justify-between items-start mb-4">
<View>
<Text className="text-xl font-bold text-gray-900 dark:text-white">{item.name}</Text>
{item.deadline && (
<Text className="text-gray-500 text-xs">Deadline: {item.deadline}</Text>
)}
</View>
<View className="bg-blue-100 px-3 py-1 rounded-full">
<Text className="text-blue-600 font-bold text-xs">{progress.toFixed(0)}%</Text>
</View>
</View>
<View className="flex-row justify-between mb-2">
<Text className="text-gray-500 text-sm">{item.current_amount.toFixed(2)} saved</Text>
<Text className="text-gray-900 dark:text-white font-bold text-sm">Target: {item.target_amount.toFixed(2)}</Text>
</View>
{/* Progress Bar */}
<View className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<View
className="h-full bg-blue-600 rounded-full"
style={{ width: `${progress}%` }}
/>
</View>
</View>
);
})
)}
</ScrollView>
</View>
);
}

View File

@ -1,31 +1,129 @@
import { StyleSheet } from 'react-native'; import { FontAwesome } from '@expo/vector-icons';
import { Link, useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
import { BarChart } from 'react-native-gifted-charts';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import TransactionList from '../../components/TransactionList';
import { getDB } from '../../db';
import { Transaction } from '../../types';
import EditScreenInfo from '@/components/EditScreenInfo'; export default function DashboardScreen() {
import { Text, View } from '@/components/Themed'; const insets = useSafeAreaInsets();
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [balance, setBalance] = useState(0);
const [income, setIncome] = useState(0);
const [expense, setExpense] = useState(0);
const [chartData, setChartData] = useState<any[]>([]);
const fetchData = useCallback(async () => {
try {
const db = getDB();
const result = await db.getAllAsync<Transaction>('SELECT * FROM transactions ORDER BY date DESC');
setTransactions(result);
let totalIncome = 0;
let totalExpense = 0;
const expensesByCategory: Record<string, number> = {};
result.forEach(t => {
if (t.type === 'income') {
totalIncome += t.amount;
} else {
totalExpense += t.amount;
expensesByCategory[t.category] = (expensesByCategory[t.category] || 0) + t.amount;
}
});
setIncome(totalIncome);
setExpense(totalExpense);
setBalance(totalIncome - totalExpense);
// Prepare Chart Data
const data = Object.keys(expensesByCategory).map(cat => ({
value: expensesByCategory[cat],
label: cat.substring(0, 3), // Short label
frontColor: '#EF4444',
}));
setChartData(data);
} catch (error) {
console.error('Error fetching data', error);
}
}, []);
useFocusEffect(
useCallback(() => {
fetchData();
}, [fetchData])
);
export default function TabOneScreen() {
return ( return (
<View style={styles.container}> <View className="flex-1 bg-gray-100 dark:bg-gray-900" style={{ paddingTop: insets.top }}>
<Text style={styles.title}>Tab One</Text> <ScrollView className="flex-1 px-6">
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" /> {/* Header */}
<EditScreenInfo path="app/(tabs)/index.tsx" /> <View className="flex-row justify-between items-center py-6">
<View>
<Text className="text-gray-500 text-sm">Total Balance</Text>
<Text className="text-4xl font-bold text-gray-900 dark:text-white">{balance.toFixed(2)}</Text>
</View>
<Link href="/modal" asChild>
<TouchableOpacity className="bg-blue-600 w-12 h-12 rounded-full items-center justify-center shadow-lg">
<FontAwesome name="plus" size={20} color="white" />
</TouchableOpacity>
</Link>
</View>
{/* Summary Cards */}
<View className="flex-row space-x-4 mb-8">
<View className="flex-1 bg-green-500 p-4 rounded-2xl shadow-sm">
<View className="flex-row items-center mb-2">
<View className="bg-white/20 w-8 h-8 rounded-full items-center justify-center mr-2">
<FontAwesome name="arrow-up" size={12} color="white" />
</View>
<Text className="text-white/80 text-sm font-medium">Income</Text>
</View>
<Text className="text-white text-xl font-bold">{income.toFixed(2)}</Text>
</View>
<View className="flex-1 bg-red-500 p-4 rounded-2xl shadow-sm">
<View className="flex-row items-center mb-2">
<View className="bg-white/20 w-8 h-8 rounded-full items-center justify-center mr-2">
<FontAwesome name="arrow-down" size={12} color="white" />
</View>
<Text className="text-white/80 text-sm font-medium">Expenses</Text>
</View>
<Text className="text-white text-xl font-bold">{expense.toFixed(2)}</Text>
</View>
</View>
{/* Spending Chart */}
{chartData.length > 0 && (
<View className="bg-white dark:bg-gray-800 p-4 rounded-2xl shadow-sm mb-6">
<Text className="text-lg font-bold text-gray-900 dark:text-white mb-4">Expenses by Category</Text>
<BarChart
data={chartData}
barWidth={30}
noOfSections={3}
barBorderRadius={4}
frontColor="#EF4444"
yAxisThickness={0}
xAxisThickness={0}
hideRules
isAnimated
/>
</View>
)}
{/* Recent Transactions */}
<View className="mb-4 flex-row justify-between items-end">
<Text className="text-xl font-bold text-gray-900 dark:text-white">Recent Transactions</Text>
<TouchableOpacity>
<Text className="text-blue-600 font-medium">See All</Text>
</TouchableOpacity>
</View>
<TransactionList transactions={transactions} />
</ScrollView>
</View> </View>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

114
app/(tabs)/investments.tsx Normal file
View File

@ -0,0 +1,114 @@
import { FontAwesome } from '@expo/vector-icons';
import { useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
import { ScrollView, Text, View } from 'react-native';
import { PieChart } from 'react-native-gifted-charts';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { getDB } from '../../db';
import { Asset } from '../../types';
export default function InvestmentsScreen() {
const insets = useSafeAreaInsets();
const [assets, setAssets] = useState<Asset[]>([]);
const [totalValue, setTotalValue] = useState(0);
const [pieData, setPieData] = useState<any[]>([]);
const fetchData = useCallback(async () => {
try {
const db = getDB();
const result = await db.getAllAsync<Asset>('SELECT * FROM assets ORDER BY value DESC');
setAssets(result);
const total = result.reduce((acc, asset) => acc + asset.value, 0);
setTotalValue(total);
// Prepare Pie Data
const colors = ['#9333EA', '#C084FC', '#A855F7', '#7E22CE', '#6B21A8'];
const data = result.map((asset, index) => ({
value: asset.value,
color: colors[index % colors.length],
text: `${Math.round((asset.value / total) * 100)}%`,
}));
setPieData(data);
} catch (error) {
console.error('Error fetching assets', error);
}
}, []);
useFocusEffect(
useCallback(() => {
fetchData();
}, [fetchData])
);
const getIconName = (type: string) => {
switch (type) {
case 'stock': return 'line-chart';
case 'crypto': return 'bitcoin';
case 'real_estate': return 'home';
case 'fund': return 'pie-chart';
default: return 'money';
}
};
return (
<View className="flex-1 bg-gray-100 dark:bg-gray-900" style={{ paddingTop: insets.top }}>
<ScrollView className="flex-1 px-6">
{/* Header */}
<View className="py-6">
<Text className="text-gray-500 text-sm">Total Portfolio Value</Text>
<Text className="text-4xl font-bold text-gray-900 dark:text-white">{totalValue.toFixed(2)}</Text>
</View>
{/* Asset Distribution */}
<View className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-8 shadow-sm items-center">
<Text className="text-lg font-bold text-gray-900 dark:text-white mb-4 self-start">Asset Allocation</Text>
{pieData.length > 0 ? (
<PieChart
data={pieData}
donut
showText
textColor="white"
radius={100}
innerRadius={60}
textSize={12}
focusOnPress
/>
) : (
<Text className="text-gray-500">No assets to display.</Text>
)}
</View>
{/* Assets List */}
<Text className="text-xl font-bold text-gray-900 dark:text-white mb-4">Your Assets</Text>
{assets.length === 0 ? (
<View className="items-center py-10">
<Text className="text-gray-400">No assets added yet.</Text>
</View>
) : (
assets.map((item) => (
<View key={item.id} className="bg-white dark:bg-gray-800 p-4 mb-3 rounded-2xl shadow-sm flex-row items-center justify-between">
<View className="flex-row items-center">
<View className="w-10 h-10 rounded-full bg-purple-100 items-center justify-center mr-4">
<FontAwesome name={getIconName(item.type)} size={16} color="#9333EA" />
</View>
<View>
<Text className="font-bold text-gray-900 dark:text-white text-base">{item.name}</Text>
<Text className="text-gray-500 text-xs capitalize">{item.type.replace('_', ' ')}</Text>
</View>
</View>
<View className="items-end">
<Text className="font-bold text-base text-gray-900 dark:text-white">{item.value.toFixed(2)}</Text>
{item.quantity && (
<Text className="text-gray-400 text-xs">{item.quantity} units</Text>
)}
</View>
</View>
))
)}
</ScrollView>
</View>
);
}

View File

@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabTwoScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab Two</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/two.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

View File

@ -4,13 +4,14 @@ import { useFonts } from 'expo-font';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen'; import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react'; import { useEffect } from 'react';
import 'react-native-reanimated'; import { initDatabase } from '../db';
import '../global.css';
import { useColorScheme } from '@/components/useColorScheme'; import { useColorScheme } from '@/components/useColorScheme';
export { export {
// Catch any errors thrown by the Layout component. // Catch any errors thrown by the Layout component.
ErrorBoundary, ErrorBoundary
} from 'expo-router'; } from 'expo-router';
export const unstable_settings = { export const unstable_settings = {
@ -32,6 +33,12 @@ export default function RootLayout() {
if (error) throw error; if (error) throw error;
}, [error]); }, [error]);
useEffect(() => {
initDatabase()
.then(() => console.log('Database initialized'))
.catch(e => console.error('Database init failed', e));
}, []);
useEffect(() => { useEffect(() => {
if (loaded) { if (loaded) {
SplashScreen.hideAsync(); SplashScreen.hideAsync();

116
app/add-asset.tsx Normal file
View File

@ -0,0 +1,116 @@
import { router } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { getDB } from '../db';
export default function AddAssetScreen() {
const insets = useSafeAreaInsets();
const [name, setName] = useState('');
const [type, setType] = useState('stock');
const [value, setValue] = useState('');
const [quantity, setQuantity] = useState('');
const handleSave = async () => {
if (!name || !value) {
alert('Please fill in name and value');
return;
}
try {
const db = getDB();
await db.runAsync(
'INSERT INTO assets (name, type, value, quantity, purchase_date) VALUES (?, ?, ?, ?, ?)',
[name, type, parseFloat(value), quantity ? parseFloat(quantity) : null, new Date().toISOString()]
);
router.back();
} catch (error) {
console.error(error);
alert('Failed to save asset');
}
};
return (
<View className="flex-1 bg-gray-100 dark:bg-gray-900">
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
<View className="bg-purple-600 pt-4 pb-6 px-6 rounded-b-3xl shadow-lg">
<Text className="text-white text-2xl font-bold mt-8 text-center">New Asset</Text>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView className="flex-1 px-6 pt-6">
{/* Type Selector */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="flex-row space-x-2 mb-6">
{['stock', 'crypto', 'real_estate', 'fund', 'other'].map((t) => (
<TouchableOpacity
key={t}
onPress={() => setType(t)}
className={`px-4 py-2 rounded-full border ${type === t ? 'bg-purple-600 border-purple-600' : 'bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700'}`}
>
<Text className={`capitalize ${type === t ? 'text-white' : 'text-gray-900 dark:text-gray-300'}`}>{t.replace('_', ' ')}</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* Value Input */}
<View className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-4 shadow-sm">
<Text className="text-gray-500 text-sm mb-2">Current Value</Text>
<View className="flex-row items-center">
<Text className="text-3xl font-bold text-gray-900 dark:text-white mr-2"></Text>
<TextInput
className="flex-1 text-4xl font-bold text-gray-900 dark:text-white"
placeholder="0.00"
keyboardType="numeric"
value={value}
onChangeText={setValue}
placeholderTextColor="#9CA3AF"
/>
</View>
</View>
{/* Details Form */}
<View className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm space-y-4">
<View>
<Text className="text-gray-500 text-sm mb-2">Asset Name</Text>
<TextInput
className="w-full bg-gray-50 dark:bg-gray-700 p-4 rounded-xl text-gray-900 dark:text-white"
placeholder="e.g. Apple Stock, Bitcoin"
value={name}
onChangeText={setName}
placeholderTextColor="#9CA3AF"
/>
</View>
<View className="mt-4">
<Text className="text-gray-500 text-sm mb-2">Quantity (Optional)</Text>
<TextInput
className="w-full bg-gray-50 dark:bg-gray-700 p-4 rounded-xl text-gray-900 dark:text-white"
placeholder="0"
keyboardType="numeric"
value={quantity}
onChangeText={setQuantity}
placeholderTextColor="#9CA3AF"
/>
</View>
</View>
</ScrollView>
<View className="p-6 bg-white dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800" style={{ paddingBottom: Math.max(insets.bottom, 24) }}>
<TouchableOpacity
className="w-full bg-purple-600 py-4 rounded-2xl shadow-lg active:bg-purple-700"
onPress={handleSave}
>
<Text className="text-white text-center font-bold text-lg">Save Asset</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</View>
);
}

114
app/add-goal.tsx Normal file
View File

@ -0,0 +1,114 @@
import { router } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { getDB } from '../db';
export default function AddGoalScreen() {
const insets = useSafeAreaInsets();
const [name, setName] = useState('');
const [targetAmount, setTargetAmount] = useState('');
const [currentAmount, setCurrentAmount] = useState('');
const [deadline, setDeadline] = useState('');
const handleSave = async () => {
if (!name || !targetAmount) {
alert('Please fill in name and target amount');
return;
}
try {
const db = getDB();
await db.runAsync(
'INSERT INTO goals (name, target_amount, current_amount, deadline) VALUES (?, ?, ?, ?)',
[name, parseFloat(targetAmount), currentAmount ? parseFloat(currentAmount) : 0, deadline]
);
router.back();
} catch (error) {
console.error(error);
alert('Failed to save goal');
}
};
return (
<View className="flex-1 bg-gray-100 dark:bg-gray-900">
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
<View className="bg-teal-600 pt-4 pb-6 px-6 rounded-b-3xl shadow-lg">
<Text className="text-white text-2xl font-bold mt-8 text-center">New Goal</Text>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView className="flex-1 px-6 pt-6">
{/* Target Amount Input */}
<View className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-4 shadow-sm">
<Text className="text-gray-500 text-sm mb-2">Target Amount</Text>
<View className="flex-row items-center">
<Text className="text-3xl font-bold text-gray-900 dark:text-white mr-2"></Text>
<TextInput
className="flex-1 text-4xl font-bold text-gray-900 dark:text-white"
placeholder="1000.00"
keyboardType="numeric"
value={targetAmount}
onChangeText={setTargetAmount}
placeholderTextColor="#9CA3AF"
/>
</View>
</View>
{/* Details Form */}
<View className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm space-y-4">
<View>
<Text className="text-gray-500 text-sm mb-2">Goal Name</Text>
<TextInput
className="w-full bg-gray-50 dark:bg-gray-700 p-4 rounded-xl text-gray-900 dark:text-white"
placeholder="e.g. New Car, Vacation"
value={name}
onChangeText={setName}
placeholderTextColor="#9CA3AF"
/>
</View>
<View className="mt-4">
<Text className="text-gray-500 text-sm mb-2">Current Savings (Optional)</Text>
<TextInput
className="w-full bg-gray-50 dark:bg-gray-700 p-4 rounded-xl text-gray-900 dark:text-white"
placeholder="0.00"
keyboardType="numeric"
value={currentAmount}
onChangeText={setCurrentAmount}
placeholderTextColor="#9CA3AF"
/>
</View>
<View className="mt-4">
<Text className="text-gray-500 text-sm mb-2">Deadline (Optional)</Text>
<TextInput
className="w-full bg-gray-50 dark:bg-gray-700 p-4 rounded-xl text-gray-900 dark:text-white"
placeholder="YYYY-MM-DD"
value={deadline}
onChangeText={setDeadline}
placeholderTextColor="#9CA3AF"
/>
</View>
</View>
</ScrollView>
<View className="p-6 bg-white dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800" style={{ paddingBottom: Math.max(insets.bottom, 24) }}>
<TouchableOpacity
className="w-full bg-teal-600 py-4 rounded-2xl shadow-lg active:bg-teal-700"
onPress={handleSave}
>
<Text className="text-white text-center font-bold text-lg">Save Goal</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</View>
);
}

View File

@ -1,35 +1,143 @@
import { router } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native'; import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { getDB } from '../db';
import EditScreenInfo from '@/components/EditScreenInfo'; export default function AddTransactionScreen() {
import { Text, View } from '@/components/Themed'; const insets = useSafeAreaInsets();
const [amount, setAmount] = useState('');
const [category, setCategory] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState<'income' | 'expense'>('expense');
const [date, setDate] = useState(new Date().toISOString().split('T')[0]); // YYYY-MM-DD
const handleSave = async () => {
if (!amount || !category) {
alert('Please fill in amount and category');
return;
}
try {
const db = getDB();
await db.runAsync(
'INSERT INTO transactions (amount, category, date, description, type) VALUES (?, ?, ?, ?, ?)',
[parseFloat(amount), category, date, description, type]
);
router.back();
} catch (error) {
console.error(error);
alert('Failed to save transaction');
}
};
export default function ModalScreen() {
return ( return (
<View style={styles.container}> <View className="flex-1 bg-gray-100 dark:bg-gray-900">
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
{/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} /> <StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
{/* Header */}
<View className="bg-blue-600 pt-4 pb-6 px-6 rounded-b-3xl shadow-lg">
<Text className="text-white text-2xl font-bold mt-8 text-center">New Transaction</Text>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView className="flex-1 px-6 pt-6">
{/* Type Selector */}
<View className="flex-row bg-white dark:bg-gray-800 rounded-xl p-1 mb-6 shadow-sm">
<TouchableOpacity
className={`flex-1 py-3 rounded-lg ${type === 'expense' ? 'bg-red-500' : 'bg-transparent'}`}
onPress={() => setType('expense')}
>
<Text className={`text-center font-semibold ${type === 'expense' ? 'text-white' : 'text-gray-500'}`}>Expense</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 py-3 rounded-lg ${type === 'income' ? 'bg-green-500' : 'bg-transparent'}`}
onPress={() => setType('income')}
>
<Text className={`text-center font-semibold ${type === 'income' ? 'text-white' : 'text-gray-500'}`}>Income</Text>
</TouchableOpacity>
</View>
{/* Amount Input */}
<View className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-4 shadow-sm">
<Text className="text-gray-500 text-sm mb-2">Amount</Text>
<View className="flex-row items-center">
<Text className="text-3xl font-bold text-gray-900 dark:text-white mr-2"></Text>
<TextInput
className="flex-1 text-4xl font-bold text-gray-900 dark:text-white"
placeholder="0.00"
keyboardType="numeric"
value={amount}
onChangeText={setAmount}
placeholderTextColor="#9CA3AF"
/>
</View>
</View>
{/* Details Form */}
<View className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm space-y-4">
<View>
<Text className="text-gray-500 text-sm mb-2">Category</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="flex-row space-x-2 mb-2">
{['Food', 'Transport', 'Shopping', 'Entertainment', 'Bills', 'Health', 'Salary', 'Investment', 'Other'].map((cat) => (
<TouchableOpacity
key={cat}
onPress={() => setCategory(cat)}
className={`px-4 py-2 rounded-full border ${category === cat ? 'bg-blue-600 border-blue-600' : 'bg-gray-50 border-gray-200 dark:bg-gray-700 dark:border-gray-600'}`}
>
<Text className={`${category === cat ? 'text-white' : 'text-gray-900 dark:text-gray-300'}`}>{cat}</Text>
</TouchableOpacity>
))}
</ScrollView>
<TextInput
className="w-full bg-gray-50 dark:bg-gray-700 p-4 rounded-xl text-gray-900 dark:text-white"
placeholder="Or type custom category..."
value={category}
onChangeText={setCategory}
placeholderTextColor="#9CA3AF"
/>
</View>
<View className="mt-4">
<Text className="text-gray-500 text-sm mb-2">Date</Text>
<TextInput
className="w-full bg-gray-50 dark:bg-gray-700 p-4 rounded-xl text-gray-900 dark:text-white"
placeholder="YYYY-MM-DD"
value={date}
onChangeText={setDate}
placeholderTextColor="#9CA3AF"
/>
</View>
<View className="mt-4">
<Text className="text-gray-500 text-sm mb-2">Description (Optional)</Text>
<TextInput
className="w-full bg-gray-50 dark:bg-gray-700 p-4 rounded-xl text-gray-900 dark:text-white"
placeholder="Add a note..."
value={description}
onChangeText={setDescription}
placeholderTextColor="#9CA3AF"
/>
</View>
</View>
</ScrollView>
{/* Save Button */}
<View className="p-6 bg-white dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800" style={{ paddingBottom: Math.max(insets.bottom, 24) }}>
<TouchableOpacity
className="w-full bg-blue-600 py-4 rounded-2xl shadow-lg active:bg-blue-700"
onPress={handleSave}
>
<Text className="text-white text-center font-bold text-lg">Save Transaction</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</View> </View>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

9
babel.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
],
plugins: ["react-native-reanimated/plugin"],
};
};

View File

@ -0,0 +1,51 @@
import { FontAwesome } from '@expo/vector-icons';
import { FlatList, Text, View } from 'react-native';
import { Transaction } from '../types';
interface TransactionListProps {
transactions: Transaction[];
onDelete?: (id: number) => void;
}
export default function TransactionList({ transactions, onDelete }: TransactionListProps) {
if (transactions.length === 0) {
return (
<View className="flex-1 justify-center items-center py-10">
<Text className="text-gray-400 text-lg">No transactions yet</Text>
</View>
);
}
return (
<FlatList
data={transactions}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={{ paddingBottom: 20 }}
renderItem={({ item }) => (
<View className="bg-white dark:bg-gray-800 p-4 mb-3 rounded-2xl shadow-sm flex-row items-center justify-between">
<View className="flex-row items-center">
<View className={`w-10 h-10 rounded-full items-center justify-center mr-4 ${item.type === 'expense' ? 'bg-red-100' : 'bg-green-100'}`}>
<FontAwesome
name={item.type === 'expense' ? 'arrow-down' : 'arrow-up'}
size={16}
color={item.type === 'expense' ? '#EF4444' : '#10B981'}
/>
</View>
<View>
<Text className="font-bold text-gray-900 dark:text-white text-base">{item.category}</Text>
<Text className="text-gray-500 text-xs">{item.date}</Text>
</View>
</View>
<View className="items-end">
<Text className={`font-bold text-base ${item.type === 'expense' ? 'text-red-500' : 'text-green-500'}`}>
{item.type === 'expense' ? '-' : '+'}{item.amount.toFixed(2)}
</Text>
{item.description && (
<Text className="text-gray-400 text-xs max-w-[100px]" numberOfLines={1}>{item.description}</Text>
)}
</View>
</View>
)}
/>
);
}

54
db/index.ts Normal file
View File

@ -0,0 +1,54 @@
import { openDatabaseSync } from 'expo-sqlite';
import { Platform } from 'react-native';
let db: any;
if (Platform.OS !== 'web') {
db = openDatabaseSync('finance.db');
} else {
// Mock DB for web to prevent crash
db = {
execSync: () => { },
getAllAsync: async () => [],
runAsync: async () => { },
getFirstAsync: async () => null,
};
}
export const initDatabase = () => {
try {
db.execSync(`
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount REAL NOT NULL,
category TEXT NOT NULL,
date TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL -- 'income' or 'expense'
);
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'stock', 'crypto', 'real_estate', etc.
value REAL NOT NULL,
quantity REAL,
purchase_date TEXT
);
CREATE TABLE IF NOT EXISTS goals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
target_amount REAL NOT NULL,
current_amount REAL NOT NULL DEFAULT 0,
deadline TEXT
);
`);
console.log('Database initialized successfully');
return Promise.resolve(true);
} catch (error) {
console.error('Error initializing database', error);
return Promise.reject(error);
}
};
export const getDB = () => db;

3
global.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

8
metro.config.js Normal file
View File

@ -0,0 +1,8 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
config.resolver.assetExts.push("wasm");
module.exports = withNativeWind(config, { input: "./global.css" });

3
nativewind-env.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

10626
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,22 +11,30 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"clsx": "^2.1.1",
"expo": "~54.0.25", "expo": "~54.0.25",
"expo-constants": "~18.0.10", "expo-constants": "~18.0.10",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.9", "expo-linking": "~8.0.9",
"expo-router": "~6.0.15", "expo-router": "~6.0.15",
"expo-splash-screen": "~31.0.11", "expo-splash-screen": "~31.0.11",
"expo-sqlite": "^16.0.9",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.8",
"expo-web-browser": "~15.0.9", "expo-web-browser": "~15.0.9",
"nativewind": "^4.2.1",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-worklets": "0.5.1", "react-native-gifted-charts": "^1.4.66",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0" "react-native-svg": "^15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",

9
tailwind.config.js Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -12,6 +12,7 @@
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".expo/types/**/*.ts", ".expo/types/**/*.ts",
"expo-env.d.ts" "expo-env.d.ts",
"nativewind-env.d.ts"
] ]
} }

25
types/index.ts Normal file
View File

@ -0,0 +1,25 @@
export interface Transaction {
id: number;
amount: number;
category: string;
date: string; // ISO 8601 YYYY-MM-DD
description?: string;
type: 'income' | 'expense';
}
export interface Asset {
id: number;
name: string;
type: 'stock' | 'crypto' | 'real_estate' | 'fund' | 'other';
value: number;
quantity?: number;
purchase_date?: string;
}
export interface Goal {
id: number;
name: string;
target_amount: number;
current_amount: number;
deadline?: string;
}