;
21 | params?: AppStackParamList[keyof AppStackParamList];
22 | options?: NativeStackNavigationOptions;
23 | };
24 |
--------------------------------------------------------------------------------
/api/migrations/save_user_budgets.sql:
--------------------------------------------------------------------------------
1 | DROP function save_user_budget;
2 |
3 | CREATE OR REPLACE FUNCTION save_user_budget(user_id integer, category_id integer, budget numeric)
4 | RETURNS void AS $$
5 | BEGIN
6 | IF EXISTS (SELECT 1 FROM monthly_budgets mb WHERE mb.category_id = save_user_budget.category_id AND mb.user_id = save_user_budget.user_id) THEN
7 | UPDATE monthly_budgets mb
8 | SET budget = save_user_budget.budget
9 | WHERE mb.user_id = save_user_budget.user_id AND mb.category_id = save_user_budget.category_id;
10 | ELSE
11 | INSERT INTO monthly_budgets (budget, user_id, category_id)
12 | VALUES (save_user_budget.budget, save_user_budget.user_id, save_user_budget.category_id);
13 | END IF;
14 | END;
15 | $$ LANGUAGE plpgsql;
16 |
17 | --select save_user_budget(3,2,700)
18 |
19 |
20 |
--------------------------------------------------------------------------------
/android/app/src/release/java/com/expensezen/ReactNativeFlipper.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the LICENSE file in the root
5 | * directory of this source tree.
6 | */
7 | package com.expensezen;
8 |
9 | import android.content.Context;
10 | import com.facebook.react.ReactInstanceManager;
11 |
12 | /**
13 | * Class responsible of loading Flipper inside your React Native application. This is the release
14 | * flavor of it so it's empty as we don't want to load Flipper.
15 | */
16 | public class ReactNativeFlipper {
17 | public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
18 | // Do nothing as we don't want to initialize Flipper on Release.
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/api/migrations/get_top_spendings.sql:
--------------------------------------------------------------------------------
1 | DROP function get_top_spendings
2 |
3 | create or replace function get_top_spendings(user_id integer, start_month date, end_month date)
4 | returns table (id integer,name text, total float, color text) AS $$
5 |
6 | BEGIN
7 | return query
8 | SELECT c.id, c.name, SUM(e.amount) as total_spending, c.color from expenses e
9 | inner join users AS u on e.user_id = u.id
10 | inner join categories as c on c.id = e.category_id
11 | WHERE
12 | u.id = get_top_spendings.user_id and e.date >= get_top_spendings.start_month and e.date <= get_top_spendings.end_month
13 | GROUP BY
14 | c.name, c.id
15 | ORDER BY
16 | total_spending desc;
17 | END;
18 |
19 | $$ language plpgsql;
20 |
21 | --SELECT get_top_spendings(1, '2023-07-01', '2023-07-31')
22 |
23 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "ExpenseZen",
4 | "slug": "ExpenseZen",
5 | "version": "1.0.0",
6 | "scheme": "com.vladi.expensezen",
7 | "orientation": "portrait",
8 | "icon": "./assets/icon.png",
9 | "userInterfaceStyle": "light",
10 | "splash": {
11 | "image": "./assets/splash.png",
12 | "resizeMode": "contain",
13 | "backgroundColor": "#ffffff"
14 | },
15 | "assetBundlePatterns": ["**/*"],
16 | "ios": {
17 | "supportsTablet": true,
18 | "bundleIdentifier": "com.vladi.expensezen"
19 | },
20 | "android": {
21 | "package": "com.vladi.expensezen",
22 | "adaptiveIcon": {
23 | "foregroundImage": "./assets/adaptive-icon.png",
24 | "backgroundColor": "#ffffff"
25 | }
26 | },
27 | "web": {
28 | "favicon": "./assets/favicon.png"
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/components/AppContainer.tsx:
--------------------------------------------------------------------------------
1 | import { extendTheme, NativeBaseProvider } from "native-base";
2 | import React from "react";
3 | import { useSelector } from "react-redux";
4 | import Navigation from "../navigation/Navigation";
5 | import { RootState } from "../redux/store";
6 |
7 | const AppContainer: React.FC = () => {
8 | const user = useSelector((state: RootState) => state.user);
9 |
10 | const isDarkTheme = user.theme === "dark";
11 |
12 | const theme = extendTheme({
13 | colors: {
14 | muted: {
15 | 50: isDarkTheme ? "#374151" : "#fafafa",
16 | 900: isDarkTheme ? "#fafafa" : "#171717",
17 | },
18 | },
19 | config: {
20 | initialColorMode: user.theme,
21 | },
22 | });
23 |
24 | return (
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default AppContainer;
32 |
--------------------------------------------------------------------------------
/api/supabase.ts:
--------------------------------------------------------------------------------
1 | import "react-native-url-polyfill/auto";
2 | import * as SecureStore from "expo-secure-store";
3 | import { createClient } from "@supabase/supabase-js";
4 | import { SUPABASE_URL, SUPABASE_ANON_KEY } from "@env";
5 | import Constants from "expo-constants";
6 |
7 | const ExposeSecureStoreAdapter = {
8 | getItem: (key: string) => {
9 | return SecureStore.getItemAsync(key);
10 | },
11 | setItem: (key: string, value: string) => {
12 | SecureStore.setItemAsync(key, value);
13 | },
14 | removeItem: (key: string) => {
15 | SecureStore.deleteItemAsync(key);
16 | },
17 | };
18 |
19 | export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
20 | auth: {
21 | storage: ExposeSecureStoreAdapter as any,
22 | autoRefreshToken: true,
23 | persistSession: true,
24 | detectSessionInUrl: false,
25 | },
26 | });
27 |
28 | export const supabaseConfig = Constants.manifest?.extra?.supabase;
29 |
--------------------------------------------------------------------------------
/components/shared/EZButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "native-base";
2 | import { ResponsiveValue } from "native-base/lib/typescript/components/types";
3 | import React from "react";
4 |
5 | interface EZButtonProps {
6 | children: any;
7 | onPress: () => void;
8 | isLoading?: boolean;
9 | variant?: ResponsiveValue<"link" | "subtle" | "solid" | "ghost" | "outline" | "unstyled">;
10 | leftIcon?: JSX.Element;
11 | }
12 |
13 | interface EZButtonStypeProp {
14 | [key: string]: any;
15 | }
16 |
17 | const EZButton: React.FC = (props) => {
18 | const { children, onPress, isLoading, variant, leftIcon } = props;
19 |
20 | return (
21 |
29 | );
30 | };
31 |
32 | export default EZButton;
33 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
14 |
17 |
--------------------------------------------------------------------------------
/screens/ChangePasswordScreen.tsx:
--------------------------------------------------------------------------------
1 | import { StatusBar } from "expo-status-bar";
2 | import { VStack } from "native-base";
3 | import React from "react";
4 | import { SafeAreaView } from "react-native";
5 | import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
6 | import { useSelector } from "react-redux";
7 | import ChangePasswordForm from "../components/ChangePasswordForm";
8 | import { RootState } from "../redux/store";
9 |
10 | const ChangePasswordScreen: React.FC = () => {
11 | const user = useSelector((state: RootState) => state.user);
12 |
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default ChangePasswordScreen;
26 |
--------------------------------------------------------------------------------
/redux/store.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers, configureStore } from "@reduxjs/toolkit";
2 | import AsyncStorage from "@react-native-async-storage/async-storage";
3 | import { persistReducer, persistStore } from "redux-persist";
4 | import userReducer from "./userReducer";
5 | import onboardReducer from "./onboardReducer";
6 | import expensesReducers from "./expensesReducers";
7 |
8 | const persistConfig = {
9 | key: "root",
10 | storage: AsyncStorage,
11 | blacklist: ["expenses"],
12 | };
13 |
14 | const rootReducer = combineReducers({
15 | user: userReducer,
16 | onboard: onboardReducer,
17 | expenses: expensesReducers,
18 | });
19 |
20 | const persistedReducer = persistReducer(persistConfig, rootReducer);
21 |
22 | export const store = configureStore({
23 | reducer: persistedReducer,
24 | middleware: (getDefaultMiddleware) =>
25 | getDefaultMiddleware({ serializableCheck: false }),
26 | });
27 |
28 | export const persistor = persistStore(store);
29 |
30 | export type RootState = ReturnType;
31 |
--------------------------------------------------------------------------------
/screens/ResetPasswordScreen.tsx:
--------------------------------------------------------------------------------
1 | import { StatusBar } from "expo-status-bar";
2 | import { VStack } from "native-base";
3 | import React from "react";
4 | import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
5 | import { SafeAreaView } from "react-native-safe-area-context";
6 | import { useSelector } from "react-redux";
7 | import ResetPasswordForm from "../components/ResetPasswordForm";
8 | import { RootState } from "../redux/store";
9 |
10 | const ResetPasswordScreen: React.FC = () => {
11 | const user = useSelector((state: RootState) => state.user);
12 |
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default ResetPasswordScreen;
26 |
--------------------------------------------------------------------------------
/navigation/Navigation.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
3 |
4 | import StackNavigator from "./StackNavigator";
5 | import { useSelector } from "react-redux";
6 | import { RootState } from "../redux/store";
7 |
8 | const Navigation: React.FC = () => {
9 | const { theme } = useSelector((state: RootState) => state.user);
10 |
11 | let navigationTheme = theme === "light" ? DefaultTheme : DarkTheme;
12 |
13 | navigationTheme = {
14 | ...navigationTheme,
15 | colors: {
16 | ...navigationTheme.colors,
17 | background: theme === "dark" ? "#1f2937" : navigationTheme.colors.background,
18 | card: theme === "dark" ? "#374151" : navigationTheme.colors.card,
19 | primary: "#222",
20 |
21 | //text: "red",
22 | },
23 | };
24 |
25 | return (
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default Navigation;
33 |
--------------------------------------------------------------------------------
/components/OnboardingStep.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useWindowDimensions } from "react-native";
3 | import { Text, VStack } from "native-base";
4 | import { Step } from "../interfaces/Step";
5 |
6 | interface OnboardingStepProps {
7 | step: Step;
8 | }
9 |
10 | const OnboardingStep: React.FC = ({ step }) => {
11 | const { width } = useWindowDimensions();
12 |
13 | const { title, description, image } = step;
14 | return (
15 |
16 |
17 | {image}
18 |
19 |
20 | {title}
21 |
22 |
23 | {description}
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default OnboardingStep;
32 |
--------------------------------------------------------------------------------
/colors.ts:
--------------------------------------------------------------------------------
1 | const COLORS = {
2 | PURPLE: {
3 | 50: "#faf5ff",
4 | 100: "#f3e8ff",
5 | 200: "#e9d5ff",
6 | 300: "#d8b4fe",
7 | 400: "#c084fc",
8 | 500: "#a855f7",
9 | 600: "#9333ea",
10 | 700: "#7e22ce",
11 | 800: "#6b21a8",
12 | 900: "#581c87",
13 | },
14 | PINK: {
15 | 300: "#f0abfc",
16 | 400: "#e879f9",
17 | 500: "#d946ef",
18 | 600: "#c026d3",
19 | 700: "#a21caf",
20 | 800: "#86198f",
21 | 900: "#701a75",
22 | },
23 | BLUE: {
24 | 400: "#60a5fa",
25 | 500: "#3b82f6",
26 | 600: "#2563eb",
27 | },
28 | EMERALD: {
29 | 300: "#6ee7b7",
30 | 400: "#34d399",
31 | 500: "#10b981",
32 | },
33 | DANGER: {
34 | 400: "#f87171",
35 | 500: "#ef4444",
36 | 600: "#dc2626",
37 | },
38 | YELLOW: {
39 | 400: "#facc15",
40 | 500: "#eab308",
41 | },
42 | MUTED: {
43 | 50: "#fafafa",
44 | 100: "#f5f5f5",
45 | 200: "#e5e5e5",
46 | 300: "#d4d4d4",
47 | 400: "#a3a3a3",
48 | 500: "#737373",
49 | 600: "#525252",
50 | 700: "#404040",
51 | 800: "#262626",
52 | 900: "#171717",
53 | },
54 | };
55 |
56 | export default COLORS;
57 |
--------------------------------------------------------------------------------
/api/services/CategoryService.ts:
--------------------------------------------------------------------------------
1 | import moment from "moment";
2 | import { supabase } from "../supabase";
3 | import { CATEGORIES } from "../../constants/Tables";
4 | import { GET_TOP_SPENDINGS } from "../../constants/PostgresFunctions";
5 |
6 | const getAllCategories = async () => {
7 | try {
8 | const { data } = await supabase.from(CATEGORIES).select("*");
9 |
10 | return data;
11 | } catch (error) {
12 | if (error instanceof Error) {
13 | console.log(error);
14 | }
15 | }
16 | };
17 |
18 | const getTopSpendingCategories = async (userId: number) => {
19 | const startOfMonth = moment().startOf("month").format("YYYY-MM-DD");
20 | const endOfMonth = moment().endOf("month").format("YYYY-MM-DD");
21 |
22 | try {
23 | const { data } = await supabase.rpc(GET_TOP_SPENDINGS, {
24 | user_id: userId,
25 | start_month: startOfMonth,
26 | end_month: endOfMonth,
27 | });
28 |
29 | return data;
30 | } catch (error) {
31 | if (error instanceof Error) {
32 | console.log(error);
33 | }
34 | }
35 | };
36 |
37 | export const CategoryService = {
38 | getAllCategories,
39 | getTopSpendingCategories,
40 | };
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.env
7 | *.p8
8 | *.p12
9 | *.key
10 | *.mobileprovision
11 | *.orig.*
12 | web-build/
13 |
14 | # macOS
15 | .DS_Store
16 |
17 | # Temporary files created by Metro to check the health of the file watcher
18 | .metro-health-check*
19 |
20 | # @generated expo-cli sync-647791c5bd841d5c91864afb91c302553d300921
21 | # The following patterns were generated by expo-cli
22 |
23 | # OSX
24 | #
25 | .DS_Store
26 |
27 | # Xcode
28 | #
29 | build/
30 | *.pbxuser
31 | !default.pbxuser
32 | *.mode1v3
33 | !default.mode1v3
34 | *.mode2v3
35 | !default.mode2v3
36 | *.perspectivev3
37 | !default.perspectivev3
38 | xcuserdata
39 | *.xccheckout
40 | *.moved-aside
41 | DerivedData
42 | *.hmap
43 | *.ipa
44 | *.xcuserstate
45 | project.xcworkspace
46 |
47 | # Android/IntelliJ
48 | #
49 | build/
50 | .idea
51 | .gradle
52 | local.properties
53 | *.iml
54 | *.hprof
55 | .cxx/
56 | *.keystore
57 | !debug.keystore
58 |
59 | # node.js
60 | #
61 | node_modules/
62 | npm-debug.log
63 | yarn-error.log
64 |
65 | # Bundle artifacts
66 | *.jsbundle
67 |
68 | # CocoaPods
69 | /ios/Pods/
70 |
71 | # Temporary files created by Metro to check the health of the file watcher
72 | .metro-health-check*
73 |
74 | # Expo
75 | .expo/
76 | web-build/
77 | dist/
78 |
79 | # @end expo-cli
--------------------------------------------------------------------------------
/components/Paginator.tsx:
--------------------------------------------------------------------------------
1 | import { useWindowDimensions, Animated } from "react-native";
2 | import React from "react";
3 | import { HStack, View } from "native-base";
4 |
5 | const Paginator: React.FC = ({ steps, scrollX }) => {
6 | const { width: windowWidth } = useWindowDimensions();
7 |
8 | const AnimatedView = Animated.createAnimatedComponent(View);
9 |
10 | return (
11 |
12 | {steps.map((_: any, index: number) => {
13 | const inputRange = [
14 | windowWidth * (index - 1),
15 | windowWidth * index,
16 | windowWidth * (index + 1),
17 | ];
18 |
19 | const width = scrollX.interpolate({
20 | inputRange,
21 | outputRange: [9, 18, 9],
22 | extrapolate: "clamp",
23 | });
24 |
25 | const opacity = scrollX.interpolate({
26 | inputRange,
27 | outputRange: [0.4, 1, 0.4],
28 | extrapolate: "clamp",
29 | });
30 |
31 | return (
32 |
40 | );
41 | })}
42 |
43 | );
44 | };
45 |
46 | export default Paginator;
47 |
--------------------------------------------------------------------------------
/assets/SVG/Google.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/utils/getCategoryIcon.js:
--------------------------------------------------------------------------------
1 | import {
2 | Ionicons,
3 | MaterialIcons,
4 | FontAwesome,
5 | MaterialCommunityIcons,
6 | FontAwesome5,
7 | } from "@expo/vector-icons";
8 | import COLORS from "../colors";
9 |
10 | export const getCategoryIcon = (name, size, color) => {
11 | let icon;
12 |
13 | switch (name) {
14 | case "Grocery":
15 | icon = ;
16 | break;
17 | case "Fuel":
18 | icon = (
19 |
20 | );
21 | break;
22 | case "Food & Drink":
23 | icon = ;
24 | break;
25 | case "Clothes":
26 | icon = (
27 |
28 | );
29 | break;
30 | case "Gifts":
31 | icon = ;
32 | break;
33 | case "Travel":
34 | icon = (
35 |
36 | );
37 | break;
38 | case "Medicine":
39 | icon = ;
40 | break;
41 | case "Bills":
42 | icon = ;
43 | break;
44 | default:
45 | icon = null;
46 | }
47 |
48 | return icon;
49 | };
50 |
--------------------------------------------------------------------------------
/api/services/CurrencyService.ts:
--------------------------------------------------------------------------------
1 | import { FREECURRENCY_API_KEY } from "@env";
2 | import { USERS } from "../../constants/Tables";
3 | import { supabase } from "../supabase";
4 | import axios from "axios";
5 |
6 | const apiUrl = "https://api.freecurrencyapi.com/v1";
7 |
8 | const updateUserCurrency = async (userId: number, currencySymbol: string, currencyCode: string) => {
9 | try {
10 | const { error } = await supabase
11 | .from(USERS)
12 | .update({ currency_code: currencyCode, currency_symbol: currencySymbol })
13 | .filter("id", "eq", userId);
14 |
15 | if (error) {
16 | throw error;
17 | }
18 | } catch (error) {
19 | if (error instanceof Error) {
20 | console.log(error);
21 | }
22 | }
23 | };
24 |
25 | const getAllCurrencies = async () => {
26 | const { data } = await axios.get(`${apiUrl}/currencies`, {
27 | params: { apikey: FREECURRENCY_API_KEY },
28 | });
29 | return data.data;
30 | };
31 |
32 | const getCurrencyConversionRate = async (baseCurrency: string, currencyToChange: string) => {
33 | try {
34 | const { data } = await axios.get(`${apiUrl}/latest`, {
35 | params: {
36 | apikey: FREECURRENCY_API_KEY,
37 | currencies: currencyToChange,
38 | base_currency: baseCurrency,
39 | },
40 | });
41 | return data.data;
42 | } catch (error) {
43 | console.log(error);
44 | }
45 | };
46 |
47 | export const CurrencyService = {
48 | updateUserCurrency,
49 | getAllCurrencies,
50 | getCurrencyConversionRate,
51 | };
52 |
--------------------------------------------------------------------------------
/components/MonthlyBudgetItem.tsx:
--------------------------------------------------------------------------------
1 | import { Box, HStack, Text } from "native-base";
2 | import React, { useState } from "react";
3 |
4 | import EZInput from "./shared/EZInput";
5 | import { Category } from "../interfaces/Category";
6 | import { authInput } from "../commonStyles";
7 |
8 | interface MonthlyBudgetItemProps {
9 | category: Category;
10 | onChange: (e: string) => void;
11 | }
12 |
13 | const MonthlyBudgetItem: React.FC = ({ category, onChange }) => {
14 | const { color, icon, name, budget } = category;
15 |
16 | const [amount, setAmount] = useState(budget!.toString().replace(".", ","));
17 |
18 | const handleChange = (e: string) => {
19 | setAmount(e);
20 | onChange(e);
21 | };
22 |
23 | return (
24 |
25 |
26 |
33 | {icon}
34 |
35 | {name}
36 |
37 | handleChange(e)}
46 | borderRadius={12}
47 | borderColor="muted.200"
48 | alignItems="flex-end"
49 | />
50 |
51 | );
52 | };
53 |
54 | export default MonthlyBudgetItem;
55 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ExpenseZen
2 | Mobile app built with React Native, TypeScript, Native Base, Supabase and Redux
3 |
4 | ## About
5 | This mobile application is designed to help users take control of their finances effortlessly.
6 | With the help of ExpenseZen, users can easily create an account, manage their spending based on various categories,
7 | visualize their expenses through intuitive pie charts, set monthly budgets, and gain valuable insights into their financial habits month by month.
8 |
9 | ## Key Features
10 | * Sign Up / Sign In
11 | * Add Expense(s) based on certain category/categories
12 | * Pie Chart: Visualize your spending patterns with a pie chart
13 | * Monthly Budgets: Set monthly budgets to help you stay on track and avoid overspending in specific categories.
14 | * Keep track on all expenses from a specific category in the current month
15 | * Dark theme support
16 |
17 | ## Screenshots
18 |
19 | ### Onboarding
20 |
21 |
22 | ### Sign Up / Sign In
23 |
24 |
25 | ### Main Screens
26 |
27 |
28 | ### Add Expense/Budget & Vizualize Expenses
29 |
30 |
31 | ### Dark theme
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/screens/CategoriesScreen.tsx:
--------------------------------------------------------------------------------
1 | import { NavigationProp, ParamListBase } from "@react-navigation/native";
2 | import { StatusBar } from "expo-status-bar";
3 | import { View } from "native-base";
4 | import React, { useLayoutEffect } from "react";
5 | import { FlatList } from "react-native";
6 | import CategoryItem from "../components/CategoryItem";
7 | import { useWindowDimensions } from "react-native";
8 | import EZHeaderTitle from "../components/shared/EzHeaderTitle";
9 | import { useSelector } from "react-redux";
10 | import { categoriesSelector } from "../redux/expensesReducers";
11 |
12 | interface CategoriesScrennProps {
13 | navigation: NavigationProp;
14 | }
15 |
16 | const CategoriesScreen: React.FC = ({ navigation }) => {
17 | const { width } = useWindowDimensions();
18 | const categories = useSelector(categoriesSelector);
19 |
20 | useLayoutEffect(() => {
21 | navigation.setOptions({
22 | headerTitle: () => Categories,
23 | });
24 | }, [navigation]);
25 |
26 | return (
27 |
28 |
29 |
30 |
31 | }
40 | data={categories}
41 | renderItem={({ item }) => }
42 | />
43 |
44 |
45 | );
46 | };
47 | export default CategoriesScreen;
48 |
--------------------------------------------------------------------------------
/components/TopSpendingCategory.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Text, useTheme, VStack } from "native-base";
2 | import React from "react";
3 | import { Category } from "../interfaces/Category";
4 | import { getCategoryIcon } from "../utils/getCategoryIcon";
5 | import { useNavigation } from "@react-navigation/native";
6 | import { Expense } from "../interfaces/Expense";
7 | import { TouchableOpacity } from "react-native";
8 | import { NativeStackNavigationProp } from "@react-navigation/native-stack";
9 | import { AppStackParamList } from "../interfaces/Navigation";
10 |
11 | interface TopSpendingCategoryProps {
12 | item: Category;
13 | expenses: Expense[];
14 | }
15 |
16 | const TopSpendingCategory: React.FC = ({ item, expenses }) => {
17 | const { name, color } = item;
18 | const {
19 | colors: { muted },
20 | } = useTheme();
21 |
22 | const navigation = useNavigation>();
23 |
24 | const goToCategoryExpenses = () => {
25 | navigation.navigate("CategoryExpenses", {
26 | expenses,
27 | name,
28 | });
29 | };
30 |
31 | return (
32 |
33 |
34 |
42 | {getCategoryIcon(name, 32, muted[50])}
43 |
44 | {name}
45 |
46 |
47 | );
48 | };
49 |
50 | export default TopSpendingCategory;
51 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import { useFonts } from "expo-font";
2 | import { LogBox } from "react-native";
3 | import SourceSansPro from "./assets/fonts/SourceSansProRegular.ttf";
4 | import SourceBold from "./assets/fonts/SourceSansProBold.ttf";
5 | import { PersistGate } from "redux-persist/integration/react";
6 | import { Provider } from "react-redux";
7 | import { persistor, store } from "./redux/store";
8 | import AppContainer from "./components/AppContainer";
9 |
10 | LogBox.ignoreLogs([
11 | "Using Math.random is not cryptographically secure! Use bcrypt.setRandomFallback to set a PRNG.",
12 | "VirtualizedLists should never be nested inside plain ScrollViews with the same orientation because it can break windowing and other functionality - use another VirtualizedList-backed container instead.",
13 | "In React 18, SSRProvider is not necessary and is a noop. You can remove it from your app.",
14 | "@supabase/gotrue-js: Stack guards not supported in this environment. Generally not an issue but may point to a very conservative transpilation environment (use ES2017 or above) that implements async/await with generators, or this is a JavaScript engine that does not support async/await stack traces. Safari is known to not support stack guards.",
15 | "Constants.platform.ios.model has been deprecated in favor of expo-device's Device.modelName property. This API will be removed in SDK 45.",
16 | ]);
17 |
18 | export default function App() {
19 | const [fontsLoaded] = useFonts({
20 | SourceSansPro,
21 | SourceBold,
22 | });
23 |
24 | if (!fontsLoaded) {
25 | return null;
26 | }
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/android/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 |
3 | buildscript {
4 | ext {
5 | buildToolsVersion = findProperty('android.buildToolsVersion') ?: '33.0.0'
6 | minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '21')
7 | compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '33')
8 | targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '33')
9 | if (findProperty('android.kotlinVersion')) {
10 | kotlinVersion = findProperty('android.kotlinVersion')
11 | }
12 | frescoVersion = findProperty('expo.frescoVersion') ?: '2.5.0'
13 |
14 | // We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP.
15 | ndkVersion = "23.1.7779620"
16 | }
17 | repositories {
18 | google()
19 | mavenCentral()
20 | }
21 | dependencies {
22 | classpath('com.android.tools.build:gradle:7.4.1')
23 | classpath('com.facebook.react:react-native-gradle-plugin')
24 | }
25 | }
26 |
27 | allprojects {
28 | repositories {
29 | maven {
30 | // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
31 | url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android'))
32 | }
33 | maven {
34 | // Android JSC is installed from npm
35 | url(new File(['node', '--print', "require.resolve('jsc-android/package.json')"].execute(null, rootDir).text.trim(), '../dist'))
36 | }
37 |
38 | google()
39 | mavenCentral()
40 | maven { url 'https://www.jitpack.io' }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/screens/RegisterScreen.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { SafeAreaView, TouchableOpacity } from "react-native";
3 | import { HStack, Text, VStack } from "native-base";
4 | import { NavigationProp, ParamListBase } from "@react-navigation/native";
5 | import RegisterForm from "../components/RegisterForm";
6 | import { StatusBar } from "expo-status-bar";
7 | import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
8 | import { useSelector } from "react-redux";
9 | import { RootState } from "../redux/store";
10 |
11 | interface RegisterScreenProps {
12 | navigation: NavigationProp;
13 | }
14 |
15 | const RegisterScreen: React.FC = ({ navigation }) => {
16 | const user = useSelector((state: RootState) => state.user);
17 |
18 | return (
19 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Already have an account ?
31 |
32 |
33 |
38 | Sign in
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | };
47 |
48 | export default RegisterScreen;
49 |
--------------------------------------------------------------------------------
/components/shared/EZProgress.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from "react";
2 | import { Animated } from "react-native";
3 | import { View } from "native-base";
4 | import { useSelector } from "react-redux";
5 | import { RootState } from "../../redux/store";
6 |
7 | interface EZProgresssProps {
8 | height: string | number;
9 | value: number;
10 | maxValue: number;
11 | color: string | undefined;
12 | }
13 |
14 | const EZProgress: React.FC = ({ height, value, maxValue, color }) => {
15 | const animatedValue = useRef(new Animated.Value(-1000)).current;
16 | const reactive = useRef(new Animated.Value(-1000)).current;
17 | const [width, setWidth] = useState(0);
18 | const user = useSelector((state: RootState) => state.user);
19 |
20 | useEffect(() => {
21 | Animated.timing(animatedValue, {
22 | toValue: reactive,
23 | duration: 700,
24 | useNativeDriver: true,
25 | }).start();
26 | }, []);
27 |
28 | useEffect(() => {
29 | let progressValue: number;
30 | if (value >= maxValue) {
31 | progressValue = 1;
32 | } else {
33 | progressValue = -width + (width * value) / maxValue;
34 | }
35 | reactive.setValue(progressValue);
36 | }, [value, width, maxValue]);
37 |
38 | const AnimatedView = Animated.createAnimatedComponent(View);
39 |
40 | return (
41 | {
43 | const width = e.nativeEvent.layout.width;
44 | setWidth(width);
45 | }}
46 | bg={user.theme === "dark" ? "muted.500" : "muted.200"}
47 | borderRadius={height}
48 | overflow="hidden"
49 | height={height}>
50 |
62 |
63 | );
64 | };
65 |
66 | export default EZProgress;
67 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable/rn_edit_text_material.xml:
--------------------------------------------------------------------------------
1 |
2 |
16 |
21 |
22 |
23 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/redux/userReducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import moment from "moment";
3 |
4 | const currentMonth = moment().format("MMMM");
5 |
6 | interface initalStateProps {
7 | id: number;
8 | firstName: string | null;
9 | lastName: string | null;
10 | email: string | null;
11 | currency: string | null;
12 | symbol: string | null;
13 | theme: "light" | "dark";
14 | month: string;
15 | }
16 |
17 | const initialState: initalStateProps = {
18 | id: 0,
19 | firstName: null,
20 | lastName: null,
21 | email: null,
22 | currency: null,
23 | symbol: null,
24 | theme: "light",
25 | month: currentMonth,
26 | };
27 |
28 | const userReducer = createSlice({
29 | name: "user",
30 |
31 | initialState: initialState,
32 | reducers: {
33 | setUser: (state, action) => {
34 | const { payload } = action;
35 | state.id = payload.id;
36 | state.firstName = payload.firstName;
37 | state.lastName = payload.lastName;
38 | state.email = payload.email;
39 | },
40 | removeUser: (state) => {
41 | state.id = 0;
42 | state.firstName = null;
43 | state.lastName = null;
44 | state.email = null;
45 | },
46 | setCurrency: (state, action) => {
47 | const { payload } = action;
48 | state.currency = payload.name;
49 | state.symbol = payload.symbol;
50 | },
51 | removeCurrency: (state) => {
52 | state.currency = null;
53 | state.symbol = null;
54 | },
55 | setTheme: (state, action) => {
56 | state.theme = action.payload;
57 | },
58 | setMonth: (state, action) => {
59 | state.month = action.payload;
60 | },
61 | },
62 | });
63 |
64 | export const setUser = userReducer.actions.setUser;
65 | export const removeUser = userReducer.actions.removeUser;
66 | export const setCurrency = userReducer.actions.setCurrency;
67 | export const removeCurrency = userReducer.actions.removeCurrency;
68 | export const setThemeAction = userReducer.actions.setTheme;
69 | export const setMonthAction = userReducer.actions.setMonth;
70 |
71 | export default userReducer.reducer;
72 |
--------------------------------------------------------------------------------
/screens/LoginScreen.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { SafeAreaView, TouchableOpacity } from "react-native";
3 | import { Text, VStack, HStack } from "native-base";
4 | import LoginForm from "../components/LoginForm";
5 | import { NavigationProp, ParamListBase } from "@react-navigation/native";
6 | import { StatusBar } from "expo-status-bar";
7 | import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
8 | import { useSelector } from "react-redux";
9 | import { RootState } from "../redux/store";
10 |
11 | interface LoginScreenProps {
12 | navigation: NavigationProp;
13 | }
14 |
15 | const LoginScreen: React.FC = ({ navigation }) => {
16 | const goToRegister = () => {
17 | navigation.navigate("Register");
18 | };
19 | const user = useSelector((state: RootState) => state.user);
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 | {/*
29 |
30 | Or continue with
31 |
32 |
33 | */}
34 |
35 |
36 | Don't have an account ?
37 |
38 |
39 |
44 | Sign up
45 |
46 |
47 |
48 |
49 |
50 |
51 | );
52 | };
53 |
54 | export default LoginScreen;
55 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "expensezen",
3 | "version": "1.0.0",
4 | "scripts": {
5 | "start": "expo start --dev-client",
6 | "android": "expo run:android",
7 | "ios": "expo run:ios",
8 | "web": "expo start --web"
9 | },
10 | "dependencies": {
11 | "@react-aria/ssr": "^3.7.0",
12 | "@react-native-async-storage/async-storage": "1.17.11",
13 | "@react-native-picker/picker": "2.4.8",
14 | "@react-navigation/bottom-tabs": "^6.5.8",
15 | "@react-navigation/native": "^6.1.6",
16 | "@react-navigation/native-stack": "^6.9.12",
17 | "@reduxjs/toolkit": "^1.9.5",
18 | "@supabase/supabase-js": "^2.25.0",
19 | "@types/bcrypt": "^5.0.0",
20 | "@types/react-dom": "18.0.10",
21 | "@types/react-native-charts-wrapper": "^0.5.5",
22 | "add": "^2.0.6",
23 | "axios": "^1.4.0",
24 | "expo": "~48.0.18",
25 | "expo-auth-session": "4.0.3",
26 | "expo-constants": "~14.2.1",
27 | "expo-font": "~11.1.1",
28 | "expo-linear-gradient": "~12.1.2",
29 | "expo-secure-store": "12.1.1",
30 | "expo-splash-screen": "~0.18.2",
31 | "expo-status-bar": "~1.4.4",
32 | "formik": "^2.4.2",
33 | "install": "^0.13.0",
34 | "isaac": "^0.0.5",
35 | "moment": "^2.29.4",
36 | "native-base": "^3.4.28",
37 | "npm": "^9.7.2",
38 | "react": "18.2.0",
39 | "react-native": "0.71.8",
40 | "react-native-bcrypt": "^2.4.0",
41 | "react-native-dotenv": "^3.4.9",
42 | "react-native-gesture-handler": "2.9.0",
43 | "react-native-keyboard-aware-scroll-view": "^0.9.5",
44 | "react-native-picker-select": "^8.0.4",
45 | "react-native-pie-chart": "^3.0.1",
46 | "react-native-reanimated": "~2.14.4",
47 | "react-native-safe-area-context": "4.5.0",
48 | "react-native-screens": "~3.20.0",
49 | "react-native-svg": "13.4.0",
50 | "react-native-svg-transformer": "^1.0.0",
51 | "react-native-url-polyfill": "^1.3.0",
52 | "react-redux": "^8.1.1",
53 | "redux-persist": "^6.0.0",
54 | "yarn": "^1.22.19",
55 | "yup": "^1.2.0"
56 | },
57 | "devDependencies": {
58 | "@babel/core": "^7.20.0",
59 | "@types/isaac": "^0.0.0",
60 | "@types/react": "^18.2.14",
61 | "@types/react-native": "^0.72.2",
62 | "@types/react-native-bcrypt": "^2.4.3",
63 | "typescript": "^4.9.4"
64 | },
65 | "private": true
66 | }
67 |
--------------------------------------------------------------------------------
/api/services/ExpenseService.ts:
--------------------------------------------------------------------------------
1 | import { CONVERT_EXPENSES_CURRENCY, GET_MONTH_EXPENSES } from "../../constants/PostgresFunctions";
2 | import { EXPENSES } from "../../constants/Tables";
3 | import { Expense } from "../../interfaces/Expense";
4 | import { getCurrentDate } from "../../utils/getCurrentDate";
5 | import { supabase } from "../supabase";
6 |
7 | // const startMonth = moment().startOf("month").format("YYYY-MM-DD");
8 | // const endMonth = moment().endOf("month").format("YYYY-MM-DD");
9 |
10 | const AddExpense = async (expense: Expense) => {
11 | const { amount, categoryId, description, userId } = expense;
12 |
13 | const currentDate = getCurrentDate();
14 |
15 | await supabase.from("expenses").insert({
16 | user_id: userId,
17 | category_id: categoryId,
18 | amount,
19 | description,
20 | date: currentDate,
21 | });
22 |
23 | try {
24 | } catch (error) {
25 | if (error instanceof Error) {
26 | console.log(error);
27 | }
28 | }
29 | };
30 |
31 | const getMonthExpenses = async (userId: number, startOfMonth: string, endOfMonth: string) => {
32 | try {
33 | const { data } = await supabase.rpc(GET_MONTH_EXPENSES, {
34 | start_month: startOfMonth,
35 | end_month: endOfMonth,
36 | user_id: userId,
37 | });
38 |
39 | return data;
40 | } catch (error) {
41 | if (error instanceof Error) {
42 | console.log(error);
43 | }
44 | }
45 | };
46 |
47 | const convertExpensesCurrency = async (userId: number, conversionRate: number) => {
48 | try {
49 | const { error } = await supabase.rpc(CONVERT_EXPENSES_CURRENCY, {
50 | user_id: userId,
51 | conversion_rate: conversionRate,
52 | });
53 |
54 | if (error) {
55 | throw error;
56 | }
57 | } catch (error) {
58 | if (error instanceof Error) {
59 | console.log(error);
60 | }
61 | }
62 | };
63 |
64 | const removeUserExpenses = async (userId: number) => {
65 | try {
66 | const { error } = await supabase.from(EXPENSES).delete().filter("user_id", "eq", userId);
67 |
68 | if (error) {
69 | throw error;
70 | }
71 | } catch (error) {
72 | if (error instanceof Error) {
73 | console.log(error);
74 | }
75 | }
76 | };
77 |
78 | export const ExpenseService = {
79 | AddExpense,
80 | getMonthExpenses,
81 | convertExpensesCurrency,
82 | removeUserExpenses,
83 | };
84 |
--------------------------------------------------------------------------------
/android/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
13 | org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
14 |
15 | # When configured, Gradle will run in incubating parallel mode.
16 | # This option should only be used with decoupled projects. More details, visit
17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
18 | # org.gradle.parallel=true
19 |
20 | # AndroidX package structure to make it clearer which packages are bundled with the
21 | # Android operating system, and which are packaged with your app's APK
22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
23 | android.useAndroidX=true
24 |
25 | # Automatically convert third-party libraries to use AndroidX
26 | android.enableJetifier=true
27 |
28 | # Version of flipper SDK to use with React Native
29 | FLIPPER_VERSION=0.125.0
30 |
31 | # Use this property to specify which architecture you want to build.
32 | # You can also override it from the CLI using
33 | # ./gradlew -PreactNativeArchitectures=x86_64
34 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
35 |
36 | # Use this property to enable support to the new architecture.
37 | # This will allow you to use TurboModules and the Fabric render in
38 | # your application. You should enable this flag either if you want
39 | # to write custom TurboModules/Fabric components OR use libraries that
40 | # are providing them.
41 | newArchEnabled=false
42 |
43 | # The hosted JavaScript engine
44 | # Supported values: expo.jsEngine = "hermes" | "jsc"
45 | expo.jsEngine=jsc
46 |
47 | # Enable GIF support in React Native images (~200 B increase)
48 | expo.gif.enabled=true
49 | # Enable webp support in React Native images (~85 KB increase)
50 | expo.webp.enabled=true
51 | # Enable animated webp support (~3.4 MB increase)
52 | # Disabled by default because iOS doesn't support animated webp
53 | expo.webp.animated=false
54 |
--------------------------------------------------------------------------------
/components/CategoryItem.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Box, HStack, Pressable, Text } from "native-base";
3 | import { Category } from "../interfaces/Category";
4 | import { getCategoryIcon } from "../utils/getCategoryIcon";
5 | import { AntDesign } from "@expo/vector-icons";
6 | import COLORS from "../colors";
7 | import { useSelector } from "react-redux";
8 | import { RootState } from "../redux/store";
9 |
10 | interface CategoryItemProps {
11 | category: Category;
12 | selectCategory?: (e: string) => void;
13 | selectedCategory?: string;
14 | disabled: boolean;
15 | }
16 |
17 | const CategoryItem: React.FC = ({
18 | category,
19 | selectCategory,
20 | selectedCategory,
21 | disabled,
22 | }) => {
23 | const user: any = useSelector((state: RootState) => state.user);
24 |
25 | const { name, color } = category;
26 |
27 | const isSelected = selectedCategory === name;
28 |
29 | return (
30 | selectCategory!(name)}
33 | _pressed={{ opacity: 0.4 }}
34 | marginX={3}
35 | flex={1}
36 | width="175px"
37 | onStartShouldSetResponder={() => true}
38 | borderColor={isSelected ? COLORS.EMERALD[400] : "muted.100"}
39 | borderWidth={user.theme === "dark" ? 0 : 1.5}
40 | height={65}
41 | bg="muted.50"
42 | style={{
43 | shadowColor: "#171717",
44 | shadowOffset: { width: 0, height: 0 },
45 | shadowOpacity: 0.1,
46 | shadowRadius: 4,
47 | }}
48 | borderRadius={20}
49 | px={2}>
50 |
51 |
58 | {getCategoryIcon(name, 24, COLORS.MUTED[50])}
59 |
60 |
61 | {name}
62 |
63 |
64 | {isSelected && (
65 |
66 |
67 |
68 | )}
69 |
70 | );
71 | };
72 |
73 | export default CategoryItem;
74 |
--------------------------------------------------------------------------------
/components/GraphCategoryItem.tsx:
--------------------------------------------------------------------------------
1 | import { Box, HStack, Text, VStack } from "native-base";
2 | import React from "react";
3 | import { TouchableOpacity } from "react-native";
4 | import { GraphCategory } from "../interfaces/GraphCategory";
5 | import { getCategoryIcon } from "../utils/getCategoryIcon";
6 | import { useSelector } from "react-redux";
7 | import { RootState } from "../redux/store";
8 | import COLORS from "../colors";
9 | import { useNavigation } from "@react-navigation/native";
10 | import { NativeStackNavigationProp } from "@react-navigation/native-stack";
11 | import { AppStackParamList } from "../interfaces/Navigation";
12 | import { Expense } from "../interfaces/Expense";
13 |
14 | interface GraphCategoryItemProps {
15 | graphCategory: GraphCategory;
16 | expenses: any[];
17 | }
18 |
19 | const GraphCategoryItem: React.FC = ({ graphCategory, expenses }) => {
20 | const { color, name } = graphCategory;
21 |
22 | const user = useSelector((state: RootState) => state.user);
23 | const navigation = useNavigation>();
24 |
25 | const goToCategoryExpenses = () => {
26 | navigation.navigate("CategoryExpenses", {
27 | expenses,
28 | name,
29 | });
30 | };
31 |
32 | const amount = expenses.reduce(
33 | (accumulator: any, currentValue: Expense) => accumulator + currentValue.amount,
34 | 0
35 | );
36 |
37 | return (
38 |
39 |
40 |
41 |
48 | {getCategoryIcon(name, 22, COLORS.MUTED[50])}
49 |
50 |
51 |
52 | {name}
53 |
54 |
55 | {expenses.length} expenses
56 |
57 |
58 |
59 |
60 | {user.symbol} {amount.toFixed(2)}
61 |
62 |
63 |
64 | );
65 | };
66 |
67 | export default GraphCategoryItem;
68 |
--------------------------------------------------------------------------------
/android/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/assets/SVG/NoData.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/android/app/src/main/java/com/vladi/ExpenseZen/MainApplication.java:
--------------------------------------------------------------------------------
1 | package com.vladi.ExpenseZen;
2 |
3 | import android.app.Application;
4 | import android.content.res.Configuration;
5 | import androidx.annotation.NonNull;
6 |
7 | import com.facebook.react.PackageList;
8 | import com.facebook.react.ReactApplication;
9 | import com.facebook.react.ReactNativeHost;
10 | import com.facebook.react.ReactPackage;
11 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
12 | import com.facebook.react.defaults.DefaultReactNativeHost;
13 | import com.facebook.soloader.SoLoader;
14 |
15 | import expo.modules.ApplicationLifecycleDispatcher;
16 | import expo.modules.ReactNativeHostWrapper;
17 |
18 | import java.util.List;
19 |
20 | public class MainApplication extends Application implements ReactApplication {
21 |
22 | private final ReactNativeHost mReactNativeHost =
23 | new ReactNativeHostWrapper(this, new DefaultReactNativeHost(this) {
24 | @Override
25 | public boolean getUseDeveloperSupport() {
26 | return BuildConfig.DEBUG;
27 | }
28 |
29 | @Override
30 | protected List getPackages() {
31 | @SuppressWarnings("UnnecessaryLocalVariable")
32 | List packages = new PackageList(this).getPackages();
33 | // Packages that cannot be autolinked yet can be added manually here, for example:
34 | // packages.add(new MyReactNativePackage());
35 | return packages;
36 | }
37 |
38 | @Override
39 | protected String getJSMainModuleName() {
40 | return "index";
41 | }
42 |
43 | @Override
44 | protected boolean isNewArchEnabled() {
45 | return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
46 | }
47 |
48 | @Override
49 | protected Boolean isHermesEnabled() {
50 | return BuildConfig.IS_HERMES_ENABLED;
51 | }
52 | });
53 |
54 | @Override
55 | public ReactNativeHost getReactNativeHost() {
56 | return mReactNativeHost;
57 | }
58 |
59 | @Override
60 | public void onCreate() {
61 | super.onCreate();
62 | SoLoader.init(this, /* native exopackage */ false);
63 | if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
64 | // If you opted-in for the New Architecture, we load the native entry point for this app.
65 | DefaultNewArchitectureEntryPoint.load();
66 | }
67 | ReactNativeFlipper.initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
68 | ApplicationLifecycleDispatcher.onApplicationCreate(this);
69 | }
70 |
71 | @Override
72 | public void onConfigurationChanged(@NonNull Configuration newConfig) {
73 | super.onConfigurationChanged(newConfig);
74 | ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig);
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/android/app/src/main/java/com/vladi/ExpenseZen/MainActivity.java:
--------------------------------------------------------------------------------
1 | package com.vladi.ExpenseZen;
2 |
3 | import android.os.Build;
4 | import android.os.Bundle;
5 |
6 | import com.facebook.react.ReactActivity;
7 | import com.facebook.react.ReactActivityDelegate;
8 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
9 | import com.facebook.react.defaults.DefaultReactActivityDelegate;
10 |
11 | import expo.modules.ReactActivityDelegateWrapper;
12 |
13 | public class MainActivity extends ReactActivity {
14 | @Override
15 | protected void onCreate(Bundle savedInstanceState) {
16 | // Set the theme to AppTheme BEFORE onCreate to support
17 | // coloring the background, status bar, and navigation bar.
18 | // This is required for expo-splash-screen.
19 | setTheme(R.style.AppTheme);
20 | super.onCreate(null);
21 | }
22 |
23 | /**
24 | * Returns the name of the main component registered from JavaScript.
25 | * This is used to schedule rendering of the component.
26 | */
27 | @Override
28 | protected String getMainComponentName() {
29 | return "main";
30 | }
31 |
32 | /**
33 | * Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link
34 | * DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React
35 | * (aka React 18) with two boolean flags.
36 | */
37 | @Override
38 | protected ReactActivityDelegate createReactActivityDelegate() {
39 | return new ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, new DefaultReactActivityDelegate(
40 | this,
41 | getMainComponentName(),
42 | // If you opted-in for the New Architecture, we enable the Fabric Renderer.
43 | DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled
44 | // If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18).
45 | DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled
46 | ));
47 | }
48 |
49 | /**
50 | * Align the back button behavior with Android S
51 | * where moving root activities to background instead of finishing activities.
52 | * @see onBackPressed
53 | */
54 | @Override
55 | public void invokeDefaultOnBackPressed() {
56 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
57 | if (!moveTaskToBack(false)) {
58 | // For non-root activities, use the default implementation to finish them.
59 | super.invokeDefaultOnBackPressed();
60 | }
61 | return;
62 | }
63 |
64 | // Use the default back button implementation on Android S
65 | // because it's doing more than {@link Activity#moveTaskToBack} in fact.
66 | super.invokeDefaultOnBackPressed();
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/android/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if %ERRORLEVEL% equ 0 goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if %ERRORLEVEL% equ 0 goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | set EXIT_CODE=%ERRORLEVEL%
84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
86 | exit /b %EXIT_CODE%
87 |
88 | :mainEnd
89 | if "%OS%"=="Windows_NT" endlocal
90 |
91 | :omega
92 |
--------------------------------------------------------------------------------
/components/MonthlyBudgetCategory.tsx:
--------------------------------------------------------------------------------
1 | import { Box, HStack, Text, useTheme, VStack } from "native-base";
2 | import React from "react";
3 | import { getCategoryIcon } from "../utils/getCategoryIcon";
4 | import EZProgress from "./shared/EZProgress";
5 | import { Budget } from "../interfaces/Budget";
6 | import { AntDesign } from "@expo/vector-icons";
7 | import COLORS from "../colors";
8 | import { useSelector } from "react-redux";
9 | import { RootState } from "../redux/store";
10 |
11 | interface MonthlyBudgetCategoryProps {
12 | budget: Budget;
13 | monthlyTotal: number;
14 | }
15 |
16 | const MonthlyBudgetCategory: React.FC = ({ budget, monthlyTotal }) => {
17 | const { budget: amount, category, color } = budget;
18 | const user: any = useSelector((state: RootState) => state.user);
19 | const {
20 | colors: { muted },
21 | } = useTheme();
22 |
23 | const budgetStatus = () => {
24 | const threshold = (amount * 75) / 100;
25 |
26 | let statusMessage = "";
27 |
28 | if (monthlyTotal >= threshold && monthlyTotal < amount) {
29 | statusMessage = "Almost exceeding";
30 | } else if (monthlyTotal >= amount) {
31 | statusMessage = "Budget exceeded !";
32 | }
33 |
34 | if (statusMessage) {
35 | return (
36 |
37 |
38 | {statusMessage}
39 |
40 | );
41 | }
42 |
43 | return null;
44 | };
45 |
46 | return (
47 |
58 |
59 |
60 |
67 | {getCategoryIcon(category, 24, muted[50])}
68 |
69 |
70 | {category}
71 |
72 |
73 |
74 |
75 |
76 | {monthlyTotal.toFixed(2)} {user.symbol} /{" "}
77 | {Number.isInteger(amount) ? amount : amount.toFixed(2)} {user.symbol}
78 |
79 |
80 |
81 |
82 | {/* {isAlmostExceeded() && (
83 |
84 |
89 | Almost exceeded
90 |
91 | )} */}
92 | {budgetStatus()}
93 |
94 | );
95 | };
96 |
97 | export default MonthlyBudgetCategory;
98 |
--------------------------------------------------------------------------------
/components/LoginProviders.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { makeRedirectUri, startAsync } from "expo-auth-session";
3 | import { Google, Facebook } from "../assets/SVG";
4 | import { HStack, Pressable } from "native-base";
5 | import { supabase } from "../api/supabase";
6 | import { SUPABASE_URL } from "@env";
7 | import * as WebBrowser from "expo-web-browser";
8 |
9 | const LoginProviders: React.FC = () => {
10 | const googleSignIn = async () => {
11 | const redirectUrl = makeRedirectUri({
12 | path: "/auth/callback",
13 | });
14 |
15 | const authResponse = await startAsync({
16 | authUrl: `${SUPABASE_URL}/auth/v1/authorize?provider=google&redirect_to=${redirectUrl}`,
17 | returnUrl: redirectUrl,
18 | });
19 |
20 | if (authResponse.type === "success") {
21 | supabase.auth.setSession({
22 | access_token: authResponse.params.access_token,
23 | refresh_token: authResponse.params.refresh_token,
24 | });
25 | }
26 | };
27 |
28 | const extractParamsFromUrl = (url: string) => {
29 | const params = new URLSearchParams(url.split("#")[1]);
30 | const data = {
31 | access_token: params.get("access_token"),
32 | expires_in: parseInt(params.get("expires_in") || "0"),
33 | refresh_token: params.get("refresh_token"),
34 | token_type: params.get("token_type"),
35 | provider_token: params.get("provider_token"),
36 | };
37 |
38 | return data;
39 | };
40 |
41 | useEffect(() => {
42 | WebBrowser.warmUpAsync();
43 |
44 | return () => {
45 | WebBrowser.coolDownAsync();
46 | };
47 | }, []);
48 |
49 | // const doSmth = async () => {
50 | // //const url = (await googleSignIn()) as string;
51 |
52 | // console.log(url);
53 | // const returnUrl = makeRedirectUri({
54 | // path: "/", // This were missing if you use react-navigation linking
55 | // useProxy: false,
56 | // });
57 |
58 | // const result = await WebBrowser.openAuthSessionAsync(url, returnUrl, {
59 | // showInRecents: true,
60 | // });
61 |
62 | // if (result.type === "success") {
63 | // const data = extractParamsFromUrl(result.url);
64 | // console.log(data);
65 | // }
66 | // };
67 |
68 | // const googleSignIn = async () => {
69 | // const result = await supabase.auth.signInWithOAuth({
70 | // provider: "google",
71 | // options: {
72 | // redirectTo: "wwphlzjxrnbilstgyzge://google-auth",
73 | // },
74 | // });
75 | // return result.data.url;
76 | // };
77 |
78 | return (
79 |
80 |
89 |
90 |
91 |
92 |
100 |
101 |
102 |
103 | );
104 | };
105 |
106 | export default LoginProviders;
107 |
--------------------------------------------------------------------------------
/navigation/StackNavigator.tsx:
--------------------------------------------------------------------------------
1 | import { createNativeStackNavigator } from "@react-navigation/native-stack";
2 | import { useEffect, useState } from "react";
3 | import { useSelector } from "react-redux";
4 | import { AppStackParamList, StackConfig } from "../interfaces/Navigation";
5 | import { RootState } from "../redux/store";
6 | import AddCurrencyScreen from "../screens/AddCurrencyScreen";
7 | import AddExpenseScreen from "../screens/AddExpenseScreen";
8 | import CategoryExpensesScreen from "../screens/CategoryExpensesScreen";
9 | import EditBudgetScreen from "../screens/EditBudgetsScreen";
10 | import LoginScreen from "../screens/LoginScreen";
11 | import OnboardingScreen from "../screens/OnboardingScreen";
12 | import RegisterScreen from "../screens/RegisterScreen";
13 | import TabNavigator from "./TabNavigator";
14 | import ResetPasswordScreen from "../screens/ResetPasswordScreen";
15 | import AboutScreen from "../screens/AboutScreen";
16 | import ChangePasswordScreen from "../screens/ChangePasswordScreen";
17 |
18 | const Stack = createNativeStackNavigator();
19 |
20 | const StackNavigator: React.FC = () => {
21 | const [initialScreen, setInitialScreen] = useState(
22 | undefined
23 | );
24 | const { onboarded } = useSelector((state: RootState) => state.onboard);
25 | const user: any = useSelector((state: RootState) => state.user);
26 |
27 | useEffect(() => {
28 | const checkOnboarding = async () => {
29 | if (!onboarded) {
30 | setInitialScreen("Onboarding");
31 | } else {
32 | if (user.email && user.currency) {
33 | setInitialScreen("Tabs");
34 | } else if (user.email && !user.currency) {
35 | setInitialScreen("Currency");
36 | } else {
37 | setInitialScreen("Login");
38 | }
39 | }
40 | };
41 | checkOnboarding();
42 | }, []);
43 |
44 | if (!initialScreen) {
45 | return null;
46 | }
47 |
48 | const routes: StackConfig[] = [
49 | { name: "Onboarding", component: OnboardingScreen, options: { headerShown: false } },
50 | { name: "Login", component: LoginScreen, options: { headerShown: false } },
51 | { name: "Register", component: RegisterScreen, options: { headerShown: false } },
52 | { name: "ResetPassword", component: ResetPasswordScreen, options: { headerShown: false } },
53 | { name: "Tabs", component: TabNavigator, options: { headerShown: false } },
54 | { name: "Currency", component: AddCurrencyScreen, options: { headerShown: false } },
55 | { name: "About", component: AboutScreen, options: { headerShown: false } },
56 | { name: "ChangePassword", component: ChangePasswordScreen, options: { headerShown: false } },
57 | {
58 | name: "CategoryExpenses",
59 | component: CategoryExpensesScreen,
60 | options: { headerTintColor: "#fff", headerBackTitleVisible: false },
61 | },
62 | {
63 | name: "AddExpense",
64 | component: AddExpenseScreen,
65 | options: { presentation: "containedModal", headerShown: false },
66 | },
67 | {
68 | name: "EditBudgets",
69 | component: EditBudgetScreen,
70 | options: { presentation: "containedModal", headerShown: false },
71 | },
72 | ];
73 |
74 | return (
75 |
76 | {routes.map((route: StackConfig, key) => (
77 |
78 | ))}
79 |
80 | );
81 | };
82 |
83 | export default StackNavigator;
84 |
--------------------------------------------------------------------------------
/components/shared/EZInput.tsx:
--------------------------------------------------------------------------------
1 | import React, { ForwardedRef, forwardRef } from "react";
2 | import { KeyboardTypeOptions, ReturnKeyTypeOptions } from "react-native";
3 | import { FormControl, Input, Text, useTheme } from "native-base";
4 | import { FontAwesome } from "@expo/vector-icons";
5 | import COLORS from "../../colors";
6 | import { useSelector } from "react-redux";
7 | import { RootState } from "../../redux/store";
8 |
9 | type inputType = "text" | "password" | undefined;
10 |
11 | interface EZInputProps {
12 | placeholder?: string;
13 | value: string | undefined;
14 | onChangeText: (e: string) => void;
15 | error?: any;
16 | type: inputType;
17 | textContentType?: any;
18 | InputRightElement?: JSX.Element;
19 | keyboardType?: KeyboardTypeOptions | undefined;
20 | returnKeyType?: ReturnKeyTypeOptions | undefined;
21 | multiline?: boolean;
22 | numberOfLines?: number;
23 | onFocus?: any;
24 | ref?: any;
25 | onSubmitEditing?: () => void;
26 | alignItems: string;
27 | flex?: number | string;
28 | formHeight?: number | string;
29 | label?: string;
30 | }
31 |
32 | interface EZInputStylesProp {
33 | [key: string]: any;
34 | }
35 |
36 | type Props = EZInputProps & EZInputStylesProp;
37 |
38 | const EZInput = forwardRef((props: Props, ref: ForwardedRef) => {
39 | const {
40 | colors: { muted },
41 | } = useTheme();
42 | const user = useSelector((state: RootState) => state.user);
43 |
44 | const {
45 | placeholder,
46 | error,
47 | value,
48 | onChangeText,
49 | type,
50 | textContentType,
51 | InputRightElement,
52 | keyboardType,
53 | multiline,
54 | numberOfLines,
55 | onFocus,
56 | returnKeyType,
57 | onSubmitEditing,
58 | alignItems,
59 | flex,
60 | formHeight,
61 | label,
62 | } = props;
63 | return (
64 |
71 | {label && (
72 |
73 | {label}
74 |
75 | )}
76 |
77 |
100 | }>
103 | {error}
104 |
105 |
106 | );
107 | });
108 |
109 | export default EZInput;
110 |
--------------------------------------------------------------------------------
/android/app/src/debug/java/com/vladi/ExpenseZen/ReactNativeFlipper.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the LICENSE file in the root
5 | * directory of this source tree.
6 | */
7 | package com.vladi.ExpenseZen;
8 |
9 | import android.content.Context;
10 | import com.facebook.flipper.android.AndroidFlipperClient;
11 | import com.facebook.flipper.android.utils.FlipperUtils;
12 | import com.facebook.flipper.core.FlipperClient;
13 | import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
14 | import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
15 | import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin;
16 | import com.facebook.flipper.plugins.inspector.DescriptorMapping;
17 | import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
18 | import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
19 | import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
20 | import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
21 | import com.facebook.react.ReactInstanceEventListener;
22 | import com.facebook.react.ReactInstanceManager;
23 | import com.facebook.react.bridge.ReactContext;
24 | import com.facebook.react.modules.network.NetworkingModule;
25 | import okhttp3.OkHttpClient;
26 |
27 | /**
28 | * Class responsible of loading Flipper inside your React Native application. This is the debug
29 | * flavor of it. Here you can add your own plugins and customize the Flipper setup.
30 | */
31 | public class ReactNativeFlipper {
32 | public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
33 | if (FlipperUtils.shouldEnableFlipper(context)) {
34 | final FlipperClient client = AndroidFlipperClient.getInstance(context);
35 |
36 | client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
37 | client.addPlugin(new DatabasesFlipperPlugin(context));
38 | client.addPlugin(new SharedPreferencesFlipperPlugin(context));
39 | client.addPlugin(CrashReporterPlugin.getInstance());
40 |
41 | NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
42 | NetworkingModule.setCustomClientBuilder(
43 | new NetworkingModule.CustomClientBuilder() {
44 | @Override
45 | public void apply(OkHttpClient.Builder builder) {
46 | builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
47 | }
48 | });
49 | client.addPlugin(networkFlipperPlugin);
50 | client.start();
51 |
52 | // Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
53 | // Hence we run if after all native modules have been initialized
54 | ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
55 | if (reactContext == null) {
56 | reactInstanceManager.addReactInstanceEventListener(
57 | new ReactInstanceEventListener() {
58 | @Override
59 | public void onReactContextInitialized(ReactContext reactContext) {
60 | reactInstanceManager.removeReactInstanceEventListener(this);
61 | reactContext.runOnNativeModulesQueueThread(
62 | new Runnable() {
63 | @Override
64 | public void run() {
65 | client.addPlugin(new FrescoFlipperPlugin());
66 | }
67 | });
68 | }
69 | });
70 | } else {
71 | client.addPlugin(new FrescoFlipperPlugin());
72 | }
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/screens/AboutScreen.tsx:
--------------------------------------------------------------------------------
1 | import { StatusBar } from "expo-status-bar";
2 | import { Text, useTheme, VStack } from "native-base";
3 | import React, { Fragment } from "react";
4 | import { ScrollView, SafeAreaView, TouchableOpacity } from "react-native";
5 | import { Ionicons } from "@expo/vector-icons";
6 | import { NavigationProp, ParamListBase } from "@react-navigation/native";
7 | import { useSelector } from "react-redux";
8 | import { RootState } from "../redux/store";
9 |
10 | interface AboutScreenProps {
11 | navigation: NavigationProp;
12 | }
13 |
14 | interface textInfo {
15 | title: string;
16 | content: JSX.Element;
17 | }
18 |
19 | const paragraphs: textInfo[] = [
20 | {
21 | title: " About ExpenseZen",
22 | content: (
23 |
24 | Welcome to ExpenseZen, your go-to solution for effortless expense tracking and budget
25 | management.
26 |
27 | ),
28 | },
29 | {
30 | title: " Key Features:",
31 | content: (
32 |
33 |
34 | {`\u2022`}{" "}
35 |
36 | Easy Expense Tracking:{" "}
37 |
38 |
39 | Record your daily expenses quickly and hassle-free.
40 |
41 |
42 |
43 | {`\u2022`}{" "}
44 |
45 | Smart Budgets:{" "}
46 |
47 |
48 | Set up monthly budgets for various spending categories.
49 |
50 |
51 |
52 | {`\u2022`}{" "}
53 |
54 | Clear Visuals:{" "}
55 |
56 |
57 | Gain insights with a simple pie chart that illustrates your spending distribution.
58 |
59 |
60 |
61 | ),
62 | },
63 | {
64 | title: " Why ExpenseZen:",
65 | content: (
66 |
67 | Tracking your expenses shouldn't be complicated. ExpenseZen keeps it simple, focusing on
68 | what truly matters - helping you stay on top of your finances.
69 |
70 | ),
71 | },
72 | {
73 | title: " Goal:",
74 | content: (
75 |
76 | ExpenseZen has the goal is aiming to simplify expense management, allowing you to make
77 | informed financial decisions without the fuss.
78 |
79 | ),
80 | },
81 | ];
82 |
83 | const AboutScreen: React.FC = ({ navigation }) => {
84 | const {
85 | colors: { muted },
86 | } = useTheme();
87 | const { theme } = useSelector((state: RootState) => state.user);
88 |
89 | return (
90 |
91 |
92 |
93 |
94 | navigation.goBack()}>
95 |
96 |
97 | {paragraphs.map((item, key) => (
98 |
99 |
100 | {item.title}
101 |
102 | {item.content}
103 |
104 | ))}
105 |
106 |
107 |
108 | );
109 | };
110 |
111 | export default AboutScreen;
112 |
--------------------------------------------------------------------------------
/assets/SVG/NoChartData.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/screens/AddCurrencyScreen.tsx:
--------------------------------------------------------------------------------
1 | import { Text, View, VStack } from "native-base";
2 | import { SafeAreaView, StyleSheet } from "react-native";
3 | import React, { useState, useEffect } from "react";
4 | import EZButton from "../components/shared/EZButton";
5 | import COLORS from "../colors";
6 | import { NavigationProp, ParamListBase } from "@react-navigation/native";
7 | import { CurrencyService } from "../api/services/CurrencyService";
8 | import { useSelector, useDispatch } from "react-redux";
9 | import { RootState } from "../redux/store";
10 | import { setCurrency } from "../redux/userReducer";
11 | import RNPickerSelect from "react-native-picker-select";
12 |
13 | interface AddCurrencyScreenProps {
14 | navigation: NavigationProp;
15 | }
16 |
17 | const AddCurrencyScreen: React.FC = ({ navigation }) => {
18 | const [currencies, setCurrencies] = useState([]);
19 | const [selectedCurrency, setSelectedCurrency] = useState();
20 | const [loading, setLoading] = useState();
21 | const { id } = useSelector((state: RootState) => state.user);
22 |
23 | const dispatch = useDispatch();
24 |
25 | useEffect(() => {
26 | getCurrencies();
27 | }, [navigation]);
28 |
29 | const getCurrencies = async () => {
30 | const data = await CurrencyService.getAllCurrencies();
31 |
32 | let currenciesArray = [];
33 |
34 | for (const item in data) {
35 | currenciesArray.push({
36 | label: `${data[item].symbol_native} ${data[item].code}`,
37 | value: `${data[item].symbol_native} ${data[item].code}`,
38 | });
39 | }
40 |
41 | setCurrencies(currenciesArray);
42 | };
43 |
44 | const saveCurrency = async () => {
45 | setLoading(true);
46 |
47 | const currencyText = selectedCurrency.split(" ");
48 |
49 | const currencySymbol = currencyText[0];
50 | const currencyCode = currencyText[1];
51 |
52 | await CurrencyService.updateUserCurrency(id, currencySymbol, currencyCode);
53 |
54 | const payload = {
55 | name: currencyCode,
56 | symbol: currencySymbol,
57 | };
58 |
59 | dispatch(setCurrency(payload));
60 |
61 | setLoading(false);
62 |
63 | goToTabs();
64 | };
65 |
66 | const goToTabs = () => {
67 | navigation.navigate("Tabs");
68 | };
69 |
70 | return (
71 |
72 |
73 |
74 |
75 | Select your currency
76 |
77 | setSelectedCurrency(value)}
81 | items={currencies}
82 | />
83 |
84 |
85 |
97 | SAVE
98 |
99 |
100 |
101 | );
102 | };
103 |
104 | export default AddCurrencyScreen;
105 |
106 | const pickerSelectStyles = StyleSheet.create({
107 | inputIOS: {
108 | color: COLORS.PURPLE[700],
109 | width: "80%",
110 | alignSelf: "center",
111 | fontSize: 16,
112 | paddingVertical: 12,
113 | paddingHorizontal: 10,
114 | borderWidth: 1,
115 | borderColor: COLORS.MUTED[200],
116 | backgroundColor: COLORS.MUTED[200],
117 | borderRadius: 12,
118 | },
119 | inputAndroid: {
120 | color: COLORS.PURPLE[700],
121 | width: "80%",
122 | alignSelf: "center",
123 | fontSize: 16,
124 | paddingVertical: 12,
125 | paddingHorizontal: 10,
126 | borderWidth: 1,
127 | borderColor: COLORS.MUTED[200],
128 | backgroundColor: COLORS.MUTED[200],
129 | borderRadius: 12,
130 | },
131 | });
132 |
--------------------------------------------------------------------------------
/components/ChangePasswordForm.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigation } from "@react-navigation/native";
2 | import { NativeStackNavigationProp } from "@react-navigation/native-stack";
3 | import { Text, useTheme, VStack } from "native-base";
4 | import React, { useState } from "react";
5 | import { Alert, TouchableOpacity } from "react-native";
6 | import { AppStackParamList } from "../interfaces/Navigation";
7 | import { Ionicons } from "@expo/vector-icons";
8 | import COLORS from "../colors";
9 | import EZInput from "./shared/EZInput";
10 | import { useFormik } from "formik";
11 | import { authInput } from "../commonStyles";
12 | import EZButton from "./shared/EZButton";
13 | import { changePasswordSchema } from "../schemas/changePasswordSchemta";
14 | import { UserService } from "../api/services/UserService";
15 | import { useSelector } from "react-redux";
16 | import { RootState } from "../redux/store";
17 |
18 | const ChangePasswordForm: React.FC = () => {
19 | const navigation = useNavigation>();
20 | const [loading, setLoading] = useState(false);
21 | const user: any = useSelector((state: RootState) => state.user);
22 | const {
23 | colors: { muted },
24 | } = useTheme();
25 |
26 | const formik = useFormik({
27 | initialValues: {
28 | password: "",
29 | repeatPassword: "",
30 | },
31 | validationSchema: changePasswordSchema,
32 | onSubmit: async (values) => {
33 | const { password } = values;
34 |
35 | try {
36 | const response = await UserService.changePassword(user.email, password);
37 |
38 | if (response?.message) {
39 | Alert.alert("Success", response.message, [
40 | {
41 | text: "OK",
42 | onPress: () => {
43 | formik.resetForm();
44 | navigation.goBack();
45 | },
46 | },
47 | ]);
48 | }
49 | } catch (error) {
50 | console.log(error);
51 | }
52 | },
53 | });
54 |
55 | const handleValue = (label: string, value: string) => {
56 | formik.setFieldValue(label, value);
57 | };
58 |
59 | const { values, errors, touched, submitForm } = formik;
60 |
61 | const submit = async () => {
62 | setLoading(true);
63 | await submitForm();
64 | setLoading(false);
65 | };
66 |
67 | return (
68 |
69 | navigation.goBack()}>
70 |
71 |
72 |
73 |
74 | Change your password
75 |
76 |
77 | Fill the info to change your password
78 |
79 |
80 |
81 | handleValue("password", e)}
87 | error={touched.password && errors.password}
88 | returnKeyType="next"
89 | borderRadius={12}
90 | borderColor="muted.200"
91 | />
92 | handleValue("repeatPassword", e)}
99 | error={touched.repeatPassword && errors.repeatPassword}
100 | returnKeyType="next"
101 | borderRadius={12}
102 | borderColor="muted.200"
103 | />
104 |
105 |
114 | Change password
115 |
116 |
117 | );
118 | };
119 |
120 | export default ChangePasswordForm;
121 |
--------------------------------------------------------------------------------
/screens/CategoryExpensesScreen.tsx:
--------------------------------------------------------------------------------
1 | import React, { useLayoutEffect } from "react";
2 | import { SafeAreaView, TouchableOpacity } from "react-native";
3 | import { Text, ScrollView, HStack, View, useTheme } from "native-base";
4 | import { NavigationProp, ParamListBase, RouteProp } from "@react-navigation/native";
5 | import { FontAwesome5 } from "@expo/vector-icons";
6 |
7 | import { Expense } from "../interfaces/Expense";
8 | import moment from "moment";
9 | import { StatusBar } from "expo-status-bar";
10 | import { getCategoryIcon } from "../utils/getCategoryIcon";
11 | import COLORS from "../colors";
12 | import { AppStackParamList } from "../interfaces/Navigation";
13 | import { useSelector } from "react-redux";
14 | import { RootState } from "../redux/store";
15 |
16 | type Props = {
17 | navigation: NavigationProp;
18 | route: RouteProp;
19 | };
20 |
21 | const CategoryExpensesScreen: React.FC = ({ navigation, route }) => {
22 | const { params } = route;
23 | const { expenses, name } = params;
24 | const { theme } = useSelector((state: RootState) => state.user);
25 | const {
26 | colors: { muted },
27 | } = useTheme();
28 |
29 | useLayoutEffect(() => {
30 | navigation.setOptions({
31 | headerStyle: {
32 | borderBottomWidth: 0,
33 | },
34 | headerShadowVisible: false,
35 | headerTitle: () => (
36 |
37 | {name}
38 |
39 | ),
40 | headerLeft: () => (
41 | navigation.goBack()}>
42 |
43 |
44 | ),
45 | });
46 | }, [navigation]);
47 |
48 | return (
49 |
50 |
51 |
52 | {expenses.map((expense: Expense, index: number) => {
53 | const parsedDate = moment(expense.payDate, "YYYY-MM-DD");
54 | const formattedDate = parsedDate.format("D MMMM");
55 |
56 | return (
57 |
58 |
59 |
60 | {getCategoryIcon(expense.name, 24, muted[900])}
61 |
62 |
63 |
69 | {index !== expenses.length - 1 && (
70 |
79 | )}
80 |
81 |
91 |
92 |
93 |
100 |
101 |
102 | # {index + 1}
103 |
104 |
105 | {expense.description}
106 |
107 |
108 | {formattedDate}
109 |
110 |
111 |
112 |
113 | {expense.amount.toFixed(2)} $
114 |
115 |
116 |
117 |
118 |
119 | );
120 | })}
121 |
122 |
123 | );
124 | };
125 |
126 | export default CategoryExpensesScreen;
127 |
--------------------------------------------------------------------------------
/components/ResetPasswordForm.tsx:
--------------------------------------------------------------------------------
1 | import { Text, useTheme, VStack } from "native-base";
2 | import React, { useState } from "react";
3 | import { TouchableOpacity, Alert } from "react-native";
4 | import COLORS from "../colors";
5 | import EZInput from "./shared/EZInput";
6 | import { authInput } from "../commonStyles";
7 | import EZButton from "./shared/EZButton";
8 | import { Ionicons } from "@expo/vector-icons";
9 | import { useNavigation } from "@react-navigation/native";
10 | import { NativeStackNavigationProp } from "@react-navigation/native-stack";
11 | import { AppStackParamList } from "../interfaces/Navigation";
12 | import { useFormik } from "formik";
13 | import { resetPasswordSchema } from "../schemas/resetPasswordScheama";
14 | import { UserService } from "../api/services/UserService";
15 |
16 | const ResetPasswordForm: React.FC = () => {
17 | const navigation = useNavigation>();
18 | const [loading, setLoading] = useState(false);
19 | const {
20 | colors: { muted },
21 | } = useTheme();
22 |
23 | const formik = useFormik({
24 | initialValues: {
25 | email: "",
26 | password: "",
27 | repeatPassword: "",
28 | },
29 | validationSchema: resetPasswordSchema,
30 | onSubmit: async (values) => {
31 | const { email, password } = values;
32 |
33 | try {
34 | const response = await UserService.resetPassword(email, password);
35 |
36 | if (response?.message === "This user does not exist !") {
37 | Alert.alert("Error", response.message, [{ text: "OK", style: "destructive" }]);
38 | } else if (response?.message === "Password resetted succesfully !") {
39 | Alert.alert("Success", response.message, [
40 | {
41 | text: "OK",
42 | onPress: () => {
43 | formik.resetForm();
44 | navigation.goBack();
45 | },
46 | },
47 | ]);
48 | }
49 | } catch (error) {
50 | console.log(error);
51 | }
52 | },
53 | });
54 |
55 | const { values, errors, submitForm, touched } = formik;
56 |
57 | const handleValue = (label: string, value: string) => {
58 | formik.setFieldValue(label, value);
59 | };
60 |
61 | const submit = async () => {
62 | setLoading(true);
63 | await submitForm();
64 | setLoading(false);
65 | };
66 |
67 | return (
68 |
69 | navigation.goBack()}>
70 |
71 |
72 |
73 |
74 | Reset your password
75 |
76 |
77 | Fill the info to change your password
78 |
79 |
80 |
81 | handleValue("email", e)}
87 | error={touched.email && errors.email}
88 | returnKeyType="next"
89 | borderRadius={12}
90 | borderColor="muted.200"
91 | />
92 | handleValue("password", e)}
99 | error={touched.password && errors.password}
100 | returnKeyType="next"
101 | borderRadius={12}
102 | borderColor="muted.200"
103 | />
104 | handleValue("repeatPassword", e)}
111 | error={touched.repeatPassword && errors.repeatPassword}
112 | returnKeyType="next"
113 | placeholder=""
114 | borderRadius={12}
115 | borderColor="muted.200"
116 | />
117 |
118 |
127 | Reset password
128 |
129 |
130 | );
131 | };
132 |
133 | export default ResetPasswordForm;
134 |
--------------------------------------------------------------------------------
/navigation/TabNavigator.tsx:
--------------------------------------------------------------------------------
1 | import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
2 | import { Fragment, useRef, useEffect } from "react";
3 | import COLORS from "../colors";
4 | import { TAB_BAR_HEIGHT } from "../constants/NavigationConstants";
5 | import HomeScreen from "../screens/HomeScreen";
6 | import { Ionicons } from "@expo/vector-icons";
7 | import GraphScreen from "../screens/GraphScreen";
8 | import CategoriesScreen from "../screens/CategoriesScreen";
9 | import SettingsScreen from "../screens/SettingsScreen";
10 | import { Animated, useWindowDimensions } from "react-native";
11 | import EZHeaderBackground from "../components/shared/EZHeaderBackground";
12 | import { useSelector } from "react-redux";
13 | import { RootState } from "../redux/store";
14 |
15 | const Tab = createBottomTabNavigator();
16 |
17 | const TabNavigator: React.FC = () => {
18 | const { width } = useWindowDimensions();
19 | const tabOffsetValue = useRef(new Animated.Value(0)).current;
20 | const user = useSelector((state: RootState) => state.user);
21 |
22 | const tabWidth = width / 4;
23 |
24 | const animateTabOffset = (index: number) => {
25 | Animated.spring(tabOffsetValue, {
26 | toValue: tabWidth * index,
27 | speed: 20,
28 | useNativeDriver: true,
29 | }).start();
30 | };
31 |
32 | const resetOffset = () => {
33 | Animated.spring(tabOffsetValue, {
34 | toValue: 0,
35 | speed: 0,
36 | useNativeDriver: true,
37 | }).start();
38 | };
39 |
40 | useEffect(() => {
41 | resetOffset();
42 | }, [user.id]);
43 |
44 | return (
45 |
46 | ,
59 | }}>
60 | (
66 |
71 | ),
72 | }}
73 | listeners={() => ({
74 | tabPress: () => animateTabOffset(0),
75 | })}
76 | />
77 | (
83 |
88 | ),
89 | }}
90 | listeners={() => ({
91 | tabPress: () => animateTabOffset(1),
92 | })}
93 | />
94 | (
100 |
105 | ),
106 | }}
107 | listeners={() => ({
108 | tabPress: () => animateTabOffset(2),
109 | })}
110 | />
111 | (
117 |
122 | ),
123 | }}
124 | listeners={() => ({
125 | tabPress: () => animateTabOffset(3),
126 | })}
127 | />
128 |
129 |
140 |
141 | );
142 | };
143 |
144 | export default TabNavigator;
145 |
--------------------------------------------------------------------------------
/screens/EditBudgetsScreen.tsx:
--------------------------------------------------------------------------------
1 | import { NavigationProp, ParamListBase } from "@react-navigation/native";
2 | import { Text, useTheme, VStack } from "native-base";
3 | import React, { useState } from "react";
4 | import { SafeAreaView, TouchableOpacity } from "react-native";
5 | import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
6 | import { AntDesign } from "@expo/vector-icons";
7 | import MonthlyBudgetItem from "../components/MonthlyBudgetItem";
8 | import { Category } from "../interfaces/Category";
9 | import { getCategoryIcon } from "../utils/getCategoryIcon";
10 | import { UserService } from "../api/services/UserService";
11 | import { useDispatch, useSelector } from "react-redux";
12 | import { RootState } from "../redux/store";
13 | import { Budget } from "../interfaces/Budget";
14 | import EZButton from "../components/shared/EZButton";
15 | import COLORS from "../colors";
16 | import { StatusBar } from "expo-status-bar";
17 | import {
18 | categoriesSelector,
19 | editBudgetsAction,
20 | monthlyBudgetsSelector,
21 | } from "../redux/expensesReducers";
22 |
23 | interface EditBudgetScreenProps {
24 | navigation: NavigationProp;
25 | }
26 |
27 | const EditBudgetScreen: React.FC = ({ navigation }) => {
28 | const user = useSelector((state: RootState) => state.user);
29 | const [buttonLoading, setButtonLoading] = useState(false);
30 | const [budgets, setBudgets] = useState([]);
31 | const dispatch = useDispatch();
32 | const monthlyBudgets = useSelector(monthlyBudgetsSelector);
33 | const categories = useSelector(categoriesSelector);
34 | const {
35 | colors: { muted },
36 | } = useTheme();
37 |
38 | const closeModal = () => {
39 | navigation.goBack();
40 | };
41 |
42 | const handleValues = (value: string, category: Category) => {
43 | const element = budgets.find((item: Budget) => item.category === category.name);
44 |
45 | let newValues;
46 | const formatAmount = value.replace(",", ".");
47 | const numericFormat = Number(formatAmount);
48 |
49 | if (element) {
50 | newValues = budgets.map((item: any) =>
51 | item === element ? { ...item, budget: numericFormat } : item
52 | );
53 | } else {
54 | newValues = [
55 | ...budgets,
56 | {
57 | budget: numericFormat,
58 | category: category.name,
59 | color: category.color,
60 | id: category.id,
61 | },
62 | ];
63 | }
64 |
65 | setBudgets(newValues);
66 | };
67 |
68 | const saveBudgets = async () => {
69 | setButtonLoading(true);
70 | await UserService.saveUserBudgets(user.id, budgets);
71 | dispatch(editBudgetsAction(budgets));
72 | setButtonLoading(false);
73 | navigation.goBack();
74 | };
75 |
76 | const budgetValues: Budget[] = [];
77 |
78 | const budgetCategories = categories!.map((category: Category) => {
79 | const budget = monthlyBudgets.find((item: Budget) => item.category === category.name);
80 |
81 | budgetValues.push(
82 | budget
83 | ? { ...budget, categoryId: category.id }
84 | : { budget: 0, category: category.name, categoryId: category.id }
85 | );
86 |
87 | return {
88 | id: category.id,
89 | name: category.name,
90 | color: category.color,
91 | icon: getCategoryIcon(category.name, 24, muted[50]),
92 | budget: budget ? budget.budget : 0,
93 | };
94 | });
95 |
96 | return (
97 |
98 |
99 |
100 |
101 |
109 |
110 |
111 |
112 | Edit your monthly budgets
113 |
114 | {budgetCategories.map((category: Category, index: number) => (
115 | handleValues(e, category)}
119 | />
120 | ))}
121 |
122 |
135 | SAVE
136 |
137 |
138 |
139 |
140 | );
141 | };
142 |
143 | export default EditBudgetScreen;
144 |
--------------------------------------------------------------------------------
/redux/expensesReducers.ts:
--------------------------------------------------------------------------------
1 | import { createSelector, createSlice } from "@reduxjs/toolkit";
2 | import moment from "moment";
3 | import { Budget } from "../interfaces/Budget";
4 | import { Category } from "../interfaces/Category";
5 | import { Expense } from "../interfaces/Expense";
6 | import { RootState } from "./store";
7 |
8 | const todayDate = moment().format("YYYY-MM-DD");
9 |
10 | interface initialStateProps {
11 | expenses: Expense[];
12 | todayTotal: number;
13 | topSpendingCategories: Category[];
14 | categories: Category[];
15 | budgets: Budget[];
16 | }
17 |
18 | const initialState: initialStateProps = {
19 | expenses: [],
20 | todayTotal: 0,
21 | topSpendingCategories: [],
22 | categories: [],
23 | budgets: [],
24 | };
25 |
26 | const expensesReducer = createSlice({
27 | name: "expenses",
28 | initialState: initialState,
29 | reducers: {
30 | setExpenses: (state, action) => {
31 | state.expenses = action.payload;
32 | },
33 |
34 | addExpense: (state, action) => {
35 | state.expenses = [...state.expenses, action.payload];
36 | },
37 | setCategories: (state, action) => {
38 | state.categories = action.payload;
39 | },
40 | setTopSpedingCategories: (state, action) => {
41 | state.topSpendingCategories = action.payload;
42 | },
43 | setBudgets: (state, action) => {
44 | state.budgets = action.payload;
45 | },
46 | editBudgets: (state, action) => {
47 | let budgets = action.payload;
48 |
49 | state.budgets = state.budgets.map((budget) => {
50 | const budgetToEdit = budgets.find((item: Budget) => item.category === budget.category);
51 |
52 | if (budgetToEdit) {
53 | return {
54 | ...budget,
55 | budget: budgetToEdit.budget,
56 | };
57 | } else {
58 | return budget;
59 | }
60 | });
61 |
62 | let budgetsToAdd: Budget[] = budgets.filter((item: any) => {
63 | const existentBudget = state.budgets.find((budget) => budget.category === item.category);
64 |
65 | if (!existentBudget) {
66 | return true;
67 | } else {
68 | return false;
69 | }
70 | });
71 |
72 | if (budgetsToAdd && budgetsToAdd.length > 0) {
73 | state.budgets = [...state.budgets, ...budgetsToAdd];
74 | }
75 | },
76 | },
77 | });
78 |
79 | //actions
80 | export const setExpensesAction = expensesReducer.actions.setExpenses;
81 | export const addExpenseAction = expensesReducer.actions.addExpense;
82 | export const setCategoriesAction = expensesReducer.actions.setCategories;
83 | export const setBudgetsAction = expensesReducer.actions.setBudgets;
84 | export const editBudgetsAction = expensesReducer.actions.editBudgets;
85 |
86 | //selectors
87 | const generalState = (state: RootState) => state.expenses;
88 | const globalState = (state: RootState) => state;
89 |
90 | export const todayTotalSelector = createSelector([generalState], (expenses: any) => {
91 | return expenses.expenses
92 | .filter((expense: Expense) => expense.payDate === todayDate)
93 | .reduce((accumulator: any, currentValue: Expense) => accumulator + currentValue.amount, 0);
94 | });
95 |
96 | export const monthTotalSelector = createSelector([globalState], (globalState: any) => {
97 | const currentMonth = globalState.user.month;
98 | const parsedMonth = moment(currentMonth, "MMMM");
99 | if (parsedMonth.isValid()) {
100 | const monthNumber = parsedMonth.month();
101 | const startOfMonth = moment().month(monthNumber).startOf("month").format("YYYY-MM-DD");
102 | const endOfMonth = moment().month(monthNumber).endOf("month").format("YYYY-MM-DD");
103 | return globalState.expenses.expenses
104 | .filter(
105 | (expense: Expense) => expense.payDate >= startOfMonth && expense.payDate <= endOfMonth
106 | )
107 | .reduce((accumulator: any, currentValue: Expense) => accumulator + currentValue.amount, 0);
108 | }
109 | });
110 |
111 | export const categoriesSelector = createSelector([generalState], (expenses: any) => {
112 | return expenses.categories;
113 | });
114 |
115 | export const topSpedingCategoriesSelector = createSelector([generalState], (expenses: any) => {
116 | const topSpendingCategories = expenses.expenses.reduce((accumulator: any, expense: any) => {
117 | const categoryName = expense.name;
118 | const existingCategory = accumulator.find((item: any) => item.name === categoryName);
119 |
120 | if (!existingCategory) {
121 | const { color, id } = expenses.categories.find(
122 | (item: Category) => item.name === categoryName
123 | );
124 | accumulator.push({
125 | name: categoryName,
126 | amount: expense.amount,
127 | color,
128 | id,
129 | });
130 | } else {
131 | existingCategory.amount += expense.amount;
132 | }
133 |
134 | return accumulator;
135 | }, []);
136 |
137 | return topSpendingCategories.sort((a: Category, b: Category) => b.amount! - a.amount!);
138 | });
139 |
140 | export const monthlyBudgetsSelector = createSelector([generalState], (expenses: any) => {
141 | return expenses.budgets;
142 | });
143 |
144 | export default expensesReducer.reducer;
145 |
--------------------------------------------------------------------------------
/components/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from "react";
2 | import { Alert, TouchableOpacity } from "react-native";
3 | import { VStack, Text, Pressable, Icon } from "native-base";
4 | import { Feather } from "@expo/vector-icons";
5 | import { useDispatch } from "react-redux";
6 | import { setCurrency, setUser } from "../redux/userReducer";
7 | import { NavigationProp, ParamListBase } from "@react-navigation/native";
8 | import { UserService } from "../api/services/UserService";
9 | import { Provider } from "../interfaces/Provider";
10 | import { useFormik } from "formik";
11 | import { loginSchema } from "../schemas/loginSchema";
12 | import EZInput from "./shared/EZInput";
13 | import COLORS from "../colors";
14 | import EZButton from "./shared/EZButton";
15 | import { authInput } from "../commonStyles";
16 |
17 | interface LoginFormProps {
18 | navigation: NavigationProp;
19 | }
20 |
21 | const LoginForm: React.FC = ({ navigation }) => {
22 | const passwordRef = useRef(null);
23 |
24 | const [passwordVisilble, setPasswordVisible] = useState(false);
25 | const [loading, setLoading] = useState(false);
26 | const formik = useFormik({
27 | initialValues: {
28 | email: "",
29 | password: "",
30 | },
31 | validationSchema: loginSchema,
32 | onSubmit: async (values) => {
33 | const { email, password } = values;
34 | const response = await UserService.loginUser(email, password, Provider.DIRECT);
35 | const message: any = response.message;
36 | if (message === "User exists") {
37 | const { id, first_name, last_name, email, currency_code, currency_symbol } = response.data;
38 |
39 | dispatch(setUser({ firstName: first_name, lastName: last_name, email, id }));
40 |
41 | if (!currency_code || !currency_symbol) {
42 | navigation.navigate("Currency");
43 | } else {
44 | dispatch(setCurrency({ name: currency_code, symbol: currency_symbol }));
45 | navigation.navigate("Tabs", { screen: "Home" });
46 | }
47 | } else {
48 | Alert.alert("Error", message);
49 | }
50 | },
51 | });
52 |
53 | useEffect(() => {
54 | navigation.addListener("focus", () => {
55 | formik.resetForm();
56 | setPasswordVisible(false);
57 | });
58 | }, [navigation]);
59 |
60 | const dispatch = useDispatch();
61 |
62 | const togglePasswordVisible = () => {
63 | setPasswordVisible((prevValue) => !prevValue);
64 | };
65 |
66 | const login = async () => {
67 | setLoading(true);
68 | await submitForm();
69 | setLoading(false);
70 | };
71 |
72 | const handleValue = (label: string, value: string) => {
73 | formik.setFieldValue(label, value);
74 | };
75 |
76 | const focusNextInput = (nextInputRef: any) => {
77 | nextInputRef.current.focus();
78 | };
79 |
80 | const goToForgotPassword = () => {
81 | navigation.navigate("ResetPassword");
82 | };
83 |
84 | const { values, errors, touched, submitForm } = formik;
85 |
86 | return (
87 |
88 |
89 |
90 | Welcome to ExpenseZen
91 |
92 |
93 | Sign in to your account
94 |
95 |
96 |
97 | handleValue("email", e)}
107 | error={touched.email && errors.email}
108 | onSubmitEditing={() => {
109 | focusNextInput(passwordRef);
110 | }}
111 | />
112 | handleValue("password", e)}
121 | borderRadius="12px"
122 | borderColor="muted.200"
123 | error={touched.password && errors.password}
124 | InputRightElement={
125 |
126 | }
130 | />
131 |
132 | }
133 | />
134 |
135 |
136 | Forgot your password ?
137 |
138 |
139 |
140 |
141 |
150 | Sign in
151 |
152 |
153 | );
154 | };
155 |
156 | export default LoginForm;
157 |
--------------------------------------------------------------------------------
/assets/SVG/SetBudget.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/SVG/Savings.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/screens/OnboardingScreen.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useRef, useState } from "react";
2 | import { FlatList, Animated, useWindowDimensions } from "react-native";
3 | import { Button, HStack, View, VStack } from "native-base";
4 | import { PieChart, Progress, Savings } from "../assets/SVG";
5 | import { Step } from "../interfaces/Step";
6 | import OnboardingStep from "../components/OnboardingStep";
7 | import Paginator from "../components/Paginator";
8 | import { Ionicons } from "@expo/vector-icons";
9 | import COLORS from "../colors";
10 | import { NavigationProp, ParamListBase } from "@react-navigation/native";
11 | import { useDispatch } from "react-redux";
12 | import { onBoard } from "../redux/onboardReducer";
13 |
14 | enum Direction {
15 | Back = "Back",
16 | Next = "Next",
17 | }
18 |
19 | const steps: Step[] = [
20 | {
21 | id: 1,
22 | title: "Track Your Savings",
23 | description:
24 | "Start your financial journey by tracking your savings. Set goals, monitor your progress, and watch your savings grow over time",
25 | image: ,
26 | },
27 | {
28 | id: 2,
29 | title: "Visualize Your Expenses",
30 | description:
31 | "Gain insights into your spending habits with our interactive pie charts. See where your money is going and make informed decisions to manage your expenses effectively",
32 | image: ,
33 | },
34 | {
35 | id: 3,
36 | title: "Stay on Top of Your Financial Goals",
37 | description:
38 | "Keep track of your financial goals with our intuitive progress bars. Set milestones, track your achievements, and stay motivated on your path to financial success.",
39 | image: ,
40 | },
41 | ];
42 |
43 | const bgs: string[] = [
44 | COLORS.BLUE[400],
45 | COLORS.EMERALD[400],
46 | COLORS.YELLOW[400],
47 | ];
48 |
49 | interface OnBoardingScreenProps {
50 | navigation: NavigationProp;
51 | }
52 |
53 | const OnboardingScreen: React.FC = ({ navigation }) => {
54 | const [currentIndex, setCurrentIndex] = useState(0);
55 | const { width, height } = useWindowDimensions();
56 | const dispatch = useDispatch();
57 |
58 | const scrollX = useRef(new Animated.Value(0)).current;
59 | const flatListRef = useRef>(null);
60 |
61 | const viewConfigRef = React.useRef({
62 | viewAreaCoveragePercentThreshold: 50,
63 | });
64 |
65 | const onChangeViewableItem = useCallback(({ viewableItems }: any) => {
66 | setCurrentIndex(viewableItems[0].index);
67 | }, []);
68 |
69 | const goDirection = async (direction: Direction) => {
70 | if (currentIndex === steps.length - 1 && direction === Direction.Next) {
71 | dispatch(onBoard());
72 | navigation.navigate("Login");
73 | } else {
74 | flatListRef.current?.scrollToIndex({
75 | index:
76 | direction === Direction.Back ? currentIndex - 1 : currentIndex + 1,
77 | });
78 | }
79 | };
80 |
81 | const backgroundColor = scrollX.interpolate({
82 | inputRange: bgs.map((_, i) => i * width),
83 | outputRange: bgs.map((bg) => bg),
84 | });
85 |
86 | const isFirstStep = currentIndex === 0;
87 |
88 | return (
89 |
97 |
98 |
99 | }
103 | pagingEnabled={true}
104 | bounces={false}
105 | horizontal={true}
106 | showsHorizontalScrollIndicator={false}
107 | data={steps}
108 | keyExtractor={(step: Step) => step.id.toString()}
109 | onScroll={Animated.event(
110 | [
111 | {
112 | nativeEvent: {
113 | contentOffset: {
114 | x: scrollX,
115 | },
116 | },
117 | },
118 | ],
119 | { useNativeDriver: false }
120 | )}
121 | scrollEventThrottle={1}
122 | onViewableItemsChanged={onChangeViewableItem}
123 | viewabilityConfig={viewConfigRef.current}
124 | />
125 |
126 |
127 |
128 |
135 |
155 |
173 |
174 |
175 | );
176 | };
177 |
178 | export default OnboardingScreen;
179 |
--------------------------------------------------------------------------------
/api/services/UserService.ts:
--------------------------------------------------------------------------------
1 | import { User } from "../../interfaces/User";
2 | import { supabase } from "../supabase";
3 | import { compareHashed } from "../../utils/compareHashed";
4 | import { hashPassword } from "../../utils/hashPassword";
5 | import { Provider } from "../../interfaces/Provider";
6 | import { Budget } from "../../interfaces/Budget";
7 | import { MONTHLY_BUDGETS, USERS } from "../../constants/Tables";
8 | import { CONVERT_BUDGETS_CURRENCY, GET_USER_BUDGETS } from "../../constants/PostgresFunctions";
9 |
10 | const registerUser = async (user: User) => {
11 | const { firstName, lastName, email, password, provider } = user;
12 |
13 | try {
14 | const { data } = await supabase
15 | .from(USERS)
16 | .select("*")
17 | .filter("email", "eq", email)
18 | .filter("provider", "eq", provider)
19 | .single();
20 |
21 | if (data) {
22 | return {
23 | title: "Try again",
24 | message: "This user is already registered !",
25 | };
26 | } else {
27 | const { error } = await supabase.from(USERS).insert(
28 | [
29 | {
30 | first_name: firstName,
31 | last_name: lastName,
32 | email,
33 | password: await hashPassword(password),
34 | provider,
35 | },
36 | ],
37 | {
38 | returning: "minimal",
39 | }
40 | );
41 | if (error) {
42 | throw error;
43 | }
44 |
45 | return {
46 | title: "Success",
47 | message: "User succesfully created !",
48 | };
49 | }
50 | } catch (error) {
51 | if (error instanceof Error) {
52 | console.log(error);
53 | }
54 | }
55 | };
56 |
57 | const resetPassword = async (email: string, newPassword: string) => {
58 | try {
59 | const { data } = await supabase.from(USERS).select("*").filter("email", "eq", email).single();
60 |
61 | if (!data) {
62 | return {
63 | message: "This user does not exist !",
64 | };
65 | } else {
66 | await supabase
67 | .from(USERS)
68 | .update({ password: await hashPassword(newPassword) })
69 | .filter("email", "eq", email);
70 |
71 | return {
72 | message: "Password resetted succesfully !",
73 | };
74 | }
75 | } catch (error) {
76 | if (error instanceof Error) {
77 | console.log(error);
78 | }
79 | }
80 | };
81 |
82 | const changePassword = async (email: string, newPassword: string) => {
83 | const hashedPassword = await hashPassword(newPassword);
84 |
85 | try {
86 | const { error } = await supabase
87 | .from(USERS)
88 | .update({ password: hashedPassword })
89 | .filter("email", "eq", email);
90 |
91 | if (error) {
92 | throw error;
93 | }
94 |
95 | return {
96 | message: "Password changed succesfully !",
97 | };
98 | } catch (error) {
99 | if (error instanceof Error) {
100 | console.log(error);
101 | }
102 | }
103 | };
104 |
105 | const loginUser = async (email: string, password: string, provider: Provider) => {
106 | try {
107 | const { data } = await supabase
108 | .from(USERS)
109 | .select("*")
110 | .filter("email", "eq", email)
111 | .filter("provider", "eq", provider)
112 | .single();
113 |
114 | if (data && (await compareHashed(password, data.password))) {
115 | return {
116 | message: "User exists",
117 | data: data,
118 | };
119 | } else {
120 | return {
121 | message: "Invalid email or password !",
122 | };
123 | }
124 | } catch (error) {
125 | return {
126 | message: error,
127 | };
128 | }
129 | };
130 |
131 | const getUserBudgets = async (userId: number) => {
132 | try {
133 | const { data } = await supabase.rpc(GET_USER_BUDGETS, {
134 | user_id: userId,
135 | });
136 |
137 | if (data) {
138 | return data;
139 | }
140 | return 0;
141 | } catch (error) {
142 | if (error instanceof Error) {
143 | console.log(error);
144 | }
145 | }
146 | };
147 |
148 | const saveUserBudgets = async (userId: number, budgets: Budget[]) => {
149 | try {
150 | for (const item of budgets) {
151 | const { data } = await supabase
152 | .from(MONTHLY_BUDGETS)
153 | .select("*")
154 | .filter("category_id", "eq", item.id)
155 | .filter("user_id", "eq", userId)
156 | .single();
157 |
158 | if (data) {
159 | await supabase
160 | .from(MONTHLY_BUDGETS)
161 | .update({ budget: item.budget })
162 | .filter("category_id", "eq", item.id)
163 | .filter("user_id", "eq", userId);
164 | } else {
165 | await supabase.from(MONTHLY_BUDGETS).insert({
166 | user_id: userId,
167 | category_id: item.id,
168 | budget: item.budget,
169 | });
170 | }
171 | }
172 | } catch (error) {
173 | if (error instanceof Error) {
174 | console.log(error);
175 | }
176 | }
177 | };
178 |
179 | const convertUserBudgetsCurrency = async (userId: number, conversionRate: number) => {
180 | try {
181 | const { error } = await supabase.rpc(CONVERT_BUDGETS_CURRENCY, {
182 | user_id: userId,
183 | conversion_rate: conversionRate,
184 | });
185 |
186 | if (error) {
187 | throw error;
188 | }
189 | } catch (error) {
190 | if (error instanceof Error) {
191 | console.log(error);
192 | }
193 | }
194 | };
195 |
196 | const removeUserBudgets = async (userId: number) => {
197 | try {
198 | const { error } = await supabase.from(MONTHLY_BUDGETS).delete().filter("user_id", "eq", userId);
199 |
200 | if (error) {
201 | throw error;
202 | }
203 | } catch (error) {
204 | if (error instanceof Error) {
205 | console.log(error);
206 | }
207 | }
208 | };
209 |
210 | export const UserService = {
211 | registerUser,
212 | loginUser,
213 | getUserBudgets,
214 | saveUserBudgets,
215 | convertUserBudgetsCurrency,
216 | resetPassword,
217 | changePassword,
218 | removeUserBudgets,
219 | };
220 |
--------------------------------------------------------------------------------
/components/RegisterForm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from "react";
2 | import { Alert } from "react-native";
3 | import { VStack, Text } from "native-base";
4 | import EZInput from "./shared/EZInput";
5 | import { useFormik } from "formik";
6 | import { registerSchema } from "../schemas/registerSchema";
7 | import COLORS from "../colors";
8 | import { UserService } from "../api/services/UserService";
9 | import { User } from "../interfaces/User";
10 | import { Provider } from "../interfaces/Provider";
11 | import { useNavigation } from "@react-navigation/native";
12 | import { NativeStackNavigationProp } from "@react-navigation/native-stack";
13 | import EZButton from "./shared/EZButton";
14 | import { AppStackParamList } from "../interfaces/Navigation";
15 | import { authInput } from "../commonStyles";
16 |
17 | const RegisterForm: React.FC = () => {
18 | const [loading, setLoading] = useState(false);
19 |
20 | const lastNameRef = useRef(null);
21 | const emailRef = useRef(null);
22 | const passwordRef = useRef(null);
23 | const repeatPasswordRef = useRef(null);
24 |
25 | const navigation = useNavigation>();
26 |
27 | const formik = useFormik({
28 | initialValues: {
29 | firstName: "",
30 | lastName: "",
31 | email: "",
32 | password: "",
33 | repeatPassword: "",
34 | },
35 | validationSchema: registerSchema,
36 | onSubmit: async (values) => {
37 | const user: User = {
38 | firstName: values.firstName,
39 | lastName: values.lastName,
40 | email: values.email,
41 |
42 | password: values.password,
43 | provider: Provider.DIRECT,
44 | };
45 |
46 | try {
47 | const response = await UserService.registerUser(user);
48 |
49 | Alert.alert(response!.title, response!.message);
50 |
51 | if (response!.title === "Try again") {
52 | formik.resetForm();
53 | } else {
54 | navigation.navigate("Login");
55 | }
56 | } catch (error) {
57 | console.log(error);
58 | }
59 | },
60 | });
61 |
62 | const { values, errors, submitForm, touched } = formik;
63 |
64 | const handleValue = (label: string, value: string) => {
65 | formik.setFieldValue(label, value);
66 | };
67 |
68 | const focusNextInput = (nextInputRef: any) => {
69 | nextInputRef.current.focus();
70 | };
71 |
72 | const submit = async () => {
73 | setLoading(true);
74 | await submitForm();
75 | setLoading(false);
76 | };
77 |
78 | return (
79 |
80 |
81 |
82 | Register account
83 |
84 |
85 | Create an account to continue
86 |
87 |
88 |
89 | handleValue("firstName", e)}
99 | error={touched.firstName && errors.firstName}
100 | onSubmitEditing={() => {
101 | focusNextInput(lastNameRef);
102 | }}
103 | />
104 | handleValue("lastName", e)}
115 | error={touched.lastName && errors.lastName}
116 | onSubmitEditing={() => {
117 | focusNextInput(emailRef);
118 | }}
119 | />
120 | handleValue("email", e)}
131 | error={touched.email && errors.email}
132 | onSubmitEditing={() => {
133 | focusNextInput(passwordRef);
134 | }}
135 | />
136 | handleValue("password", e)}
148 | error={touched.password && errors.password}
149 | onSubmitEditing={() => {
150 | focusNextInput(repeatPasswordRef);
151 | }}
152 | />
153 | handleValue("repeatPassword", e)}
165 | error={touched.repeatPassword && errors.repeatPassword}
166 | />
167 |
168 |
169 |
178 | Register
179 |
180 |
181 | );
182 | };
183 |
184 | export default RegisterForm;
185 |
--------------------------------------------------------------------------------
/screens/GraphScreen.tsx:
--------------------------------------------------------------------------------
1 | import { NavigationProp, ParamListBase } from "@react-navigation/native";
2 | import {
3 | View,
4 | Text,
5 | Box,
6 | VStack,
7 | Circle,
8 | HStack,
9 | Skeleton,
10 | ScrollView,
11 | FlatList,
12 | useTheme,
13 | } from "native-base";
14 | import React, { useLayoutEffect, useState, Fragment } from "react";
15 | import PieChart from "react-native-pie-chart";
16 | import { useSelector } from "react-redux";
17 | import { Expense } from "../interfaces/Expense";
18 | import { RootState } from "../redux/store";
19 | import { GraphCategory } from "../interfaces/GraphCategory";
20 | import GraphCategoryItem from "../components/GraphCategoryItem";
21 | import { StatusBar } from "expo-status-bar";
22 | import { monthTotalSelector } from "../redux/expensesReducers";
23 | import EZHeaderTitle from "../components/shared/EzHeaderTitle";
24 | import { NoChartData } from "../assets/SVG";
25 |
26 | interface GraphScreenProps {
27 | navigation: NavigationProp;
28 | }
29 |
30 | const GraphScreen: React.FC = ({ navigation }) => {
31 | const user = useSelector((state: RootState) => state.user);
32 | const [graphCategories, setGraphCategories] = useState([]);
33 | const [loading, setLoading] = useState(false);
34 | const [series, setSeries] = useState([1]);
35 | const [colors, setColors] = useState(["red"]);
36 | const { expenses } = useSelector((state: RootState) => state.expenses);
37 | const monthTotal = useSelector(monthTotalSelector);
38 | const {
39 | colors: { muted },
40 | } = useTheme();
41 |
42 | useLayoutEffect(() => {
43 | navigation.setOptions({
44 | headerTitle: () => Graph Reports,
45 | });
46 |
47 | getGeneralInfo();
48 | }, [navigation]);
49 |
50 | const getGeneralInfo = async () => {
51 | setLoading(true);
52 |
53 | const categorySummary = expenses!.reduce((acc: any, current: any) => {
54 | const { name, amount, color } = current;
55 | if (acc.hasOwnProperty(name)) {
56 | acc[name].amount += amount;
57 | acc[name].expenses += 1;
58 | acc[name].color = color;
59 | } else {
60 | acc[name] = { amount, expenses: 1, color };
61 | }
62 | return acc;
63 | }, {});
64 |
65 | if (expenses && expenses.length > 0) {
66 | const graphCategories = Object.keys(categorySummary).map((category) => ({
67 | name: category,
68 | amount: categorySummary[category].amount,
69 | expenses: categorySummary[category].expenses,
70 | color: categorySummary[category].color,
71 | }));
72 |
73 | setGraphCategories(graphCategories);
74 |
75 | setSeries(graphCategories.map((item: Expense) => item.amount));
76 | setColors(graphCategories.map((item: GraphCategory) => item.color));
77 | }
78 |
79 | setLoading(false);
80 | };
81 |
82 | return (
83 |
84 |
85 | 0 ? "space-between" : "center"}
88 | w="90%"
89 | py={4}
90 | px={2}
91 | style={{
92 | shadowColor: "#171717",
93 | shadowOffset: { width: 0, height: 0 },
94 | shadowOpacity: 0.1,
95 | shadowRadius: 4,
96 | }}
97 | bg="muted.50"
98 | borderRadius={20}>
99 | {expenses && expenses.length > 0 ? (
100 |
101 |
102 | {loading ? (
103 |
104 | ) : (
105 |
112 | )}
113 |
114 | {!loading && (
115 |
116 |
117 | Total
118 |
119 |
120 | {user.symbol} {monthTotal.toFixed(2)}
121 |
122 |
123 | )}
124 |
125 |
126 |
127 |
128 |
129 | {graphCategories.map((graphCategory: GraphCategory, key: number) => {
130 | const categoryPercent = (graphCategory.amount * 100) / monthTotal;
131 | return (
132 |
133 |
134 |
135 | {graphCategory.name} ({categoryPercent.toFixed(2)}%)
136 |
137 |
138 | );
139 | })}
140 |
141 |
142 |
143 |
144 | ) : (
145 |
146 |
147 |
148 | No data to display
149 |
150 |
151 | )}
152 |
153 |
154 | item.name}
161 | ItemSeparatorComponent={() => }
162 | renderItem={({ item }) => (
163 | expense.name === item.name)}
165 | graphCategory={item}
166 | />
167 | )}
168 | />
169 |
170 | );
171 | };
172 | export default GraphScreen;
173 |
--------------------------------------------------------------------------------
/assets/SVG/Progress.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/screens/AddExpenseScreen.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from "react";
2 | import { HStack, Text, VStack, View, useTheme } from "native-base";
3 | import { SafeAreaView, FlatList, useWindowDimensions, TouchableOpacity } from "react-native";
4 | import { AntDesign, FontAwesome } from "@expo/vector-icons";
5 | import { NavigationProp, ParamListBase } from "@react-navigation/native";
6 | import EZInput from "../components/shared/EZInput";
7 | import { Category } from "../interfaces/Category";
8 | import CategoryItem from "../components/CategoryItem";
9 | import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
10 | import { useFormik } from "formik";
11 | import { expenseSchema } from "../schemas/expenseSchema";
12 | import EZButton from "../components/shared/EZButton";
13 | import COLORS from "../colors";
14 | import { ExpenseService } from "../api/services/ExpenseService";
15 | import { useDispatch, useSelector } from "react-redux";
16 | import { RootState } from "../redux/store";
17 | import { StatusBar } from "expo-status-bar";
18 | import { addExpenseAction, categoriesSelector } from "../redux/expensesReducers";
19 | import moment from "moment";
20 | import { authInput } from "../commonStyles";
21 |
22 | interface AddExpenseScreenProps {
23 | navigation: NavigationProp;
24 | }
25 |
26 | const AddExpenseScreen: React.FC = ({ navigation }) => {
27 | const { width } = useWindowDimensions();
28 | const dispatch = useDispatch();
29 | const scrollRef = useRef(null);
30 | const {
31 | colors: { muted },
32 | } = useTheme();
33 |
34 | const user = useSelector((state: RootState) => state.user);
35 | const categories = useSelector(categoriesSelector);
36 | const [loading, setLoading] = useState(false);
37 | const formik = useFormik({
38 | initialValues: {
39 | amount: "",
40 | category: "",
41 | description: "",
42 | },
43 | validationSchema: expenseSchema,
44 | onSubmit: async (values) => {
45 | const { amount, category, description } = values;
46 |
47 | try {
48 | const currentCategory = categories.find((item: Category) => item.name === category);
49 |
50 | const formatAmount = amount.replace(",", ".");
51 | const numericFormat = Number(formatAmount);
52 |
53 | const expense = {
54 | userId: Number(user.id),
55 | categoryId: Number(currentCategory!.id),
56 | amount: numericFormat,
57 | description,
58 | };
59 |
60 | const today = moment().format("YYYY-MM-DD");
61 | await ExpenseService.AddExpense(expense);
62 |
63 | dispatch(
64 | addExpenseAction({
65 | ...expense,
66 | payDate: today,
67 | name: category,
68 | color: currentCategory.color,
69 | })
70 | );
71 |
72 | navigation.goBack();
73 | } catch (error) {
74 | console.log(error);
75 | }
76 | },
77 | });
78 |
79 | const handleValue = (label: string, value: string) => {
80 | if (label === "amount" && values.amount.includes(",") && value.slice(-1) === ",") {
81 | formik.setFieldValue(label, value.slice(0, -1));
82 | } else {
83 | formik.setFieldValue(label, value);
84 | }
85 | };
86 |
87 | const selectCategory = (value: string) => {
88 | formik.setFieldValue("category", value);
89 | };
90 |
91 | const { values, errors, submitForm, touched } = formik;
92 |
93 | const addExpense = async () => {
94 | setLoading(true);
95 | await submitForm();
96 | setLoading(false);
97 | };
98 |
99 | return (
100 |
101 |
102 | scrollRef.current.scrollToEnd()}>
106 | navigation.goBack()}>
109 |
110 |
111 |
112 |
113 |
114 |
115 | Add new expense
116 |
117 | handleValue("amount", e)}
124 | label={`Enter amount ${user.symbol}`}
125 | borderRadius={12}
126 | borderColor="muted.100"
127 | placeholderTextColor="muted.300"
128 | _focus={{
129 | backgroundColor: "transparent",
130 | color: "purple.700",
131 | placeholderTextColor: "purple.700",
132 | }}
133 | error={touched.amount && errors.amount}
134 | />
135 |
136 | Category
137 |
138 | }
147 | data={categories}
148 | renderItem={({ item }) => (
149 |
155 | )}
156 | />
157 |
158 | {touched.category && errors.category && (
159 |
160 |
161 |
162 | {errors.category}
163 |
164 |
165 | )}
166 | handleValue("description", e)}
173 | borderRadius={12}
174 | borderColor="muted.200"
175 | error={touched.description && errors.description}
176 | />
177 |
178 |
191 | SAVE
192 |
193 |
194 |
195 |
196 |
197 | );
198 | };
199 |
200 | export default AddExpenseScreen;
201 |
--------------------------------------------------------------------------------