├── ios ├── Podfile.properties.json ├── goodbyemoney │ ├── Images.xcassets │ │ ├── Contents.json │ │ ├── SplashScreenBackground.imageset │ │ │ ├── image.png │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── noop-file.swift │ ├── AppDelegate.h │ ├── main.m │ ├── goodbyemoney.entitlements │ ├── Supporting │ │ └── Expo.plist │ ├── Info.plist │ ├── SplashScreen.storyboard │ └── AppDelegate.mm ├── sentry.properties ├── goodbyemoney.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── .gitignore ├── .xcode.env ├── Podfile ├── goodbyemoney.xcodeproj │ ├── xcshareddata │ │ └── xcschemes │ │ │ └── goodbyemoney.xcscheme │ └── project.pbxproj └── Podfile.lock ├── tsconfig.json ├── assets ├── icon.png ├── splash.png ├── favicon.png └── adaptive-icon.png ├── android └── sentry.properties ├── screens ├── index.tsx ├── Settings.tsx ├── Home.tsx ├── Expenses.tsx ├── Reports.tsx ├── Categories.tsx └── Add.tsx ├── types ├── recurrence.ts ├── expenses-group.ts └── report-page.ts ├── metro.config.js ├── babel.config.js ├── .gitignore ├── realm.ts ├── app.config.js ├── theme └── index.ts ├── utils ├── number.ts ├── recurrences.ts ├── date.ts └── expenses.ts ├── index.js ├── eas.json ├── README.md ├── models ├── category.ts └── expense.ts ├── components ├── TabBarIcon.tsx ├── CategoryRow.tsx ├── ExpenseRow.tsx ├── ListItem.tsx ├── ExpensesList.tsx ├── charts │ ├── MonthlyChart.tsx │ ├── WeeklyChart.tsx │ └── YearlyChart.tsx └── ReportPage.tsx ├── package.json └── App.tsx /ios/Podfile.properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo.jsEngine": "jsc" 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } 5 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/goodbyemoney-reactnative/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/goodbyemoney-reactnative/HEAD/assets/splash.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/goodbyemoney-reactnative/HEAD/assets/favicon.png -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/goodbyemoney-reactnative/HEAD/assets/adaptive-icon.png -------------------------------------------------------------------------------- /ios/goodbyemoney/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "expo" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /android/sentry.properties: -------------------------------------------------------------------------------- 1 | defaults.url=https://sentry.io/ 2 | defaults.org=lazars-experiments 3 | defaults.project=goodbyemoney-reactnative 4 | -------------------------------------------------------------------------------- /ios/sentry.properties: -------------------------------------------------------------------------------- 1 | defaults.url=https://sentry.io/ 2 | defaults.org=lazars-experiments 3 | defaults.project=goodbyemoney-reactnative 4 | -------------------------------------------------------------------------------- /ios/goodbyemoney/noop-file.swift: -------------------------------------------------------------------------------- 1 | // 2 | // @generated 3 | // A blank Swift file must be created for native modules with Swift files to work correctly. 4 | // 5 | -------------------------------------------------------------------------------- /screens/index.tsx: -------------------------------------------------------------------------------- 1 | export { Expenses } from './Expenses'; 2 | export { Reports } from './Reports'; 3 | export { Add } from './Add'; 4 | export { Settings } from './Settings'; 5 | -------------------------------------------------------------------------------- /types/recurrence.ts: -------------------------------------------------------------------------------- 1 | export enum Recurrence { 2 | None = 'none', 3 | Daily = 'daily', 4 | Weekly = 'weekly', 5 | Monthly = 'monthly', 6 | Yearly = 'yearly', 7 | } 8 | -------------------------------------------------------------------------------- /types/expenses-group.ts: -------------------------------------------------------------------------------- 1 | import { Expense } from '../models/expense'; 2 | 3 | export type ExpensesGroup = { 4 | day: string; 5 | expenses: Expense[]; 6 | total: number; 7 | }; 8 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more https://docs.expo.io/guides/customizing-metro 2 | const { getDefaultConfig } = require('expo/metro-config'); 3 | 4 | module.exports = getDefaultConfig(__dirname); 5 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | plugins: ['react-native-reanimated/plugin'], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | .env -------------------------------------------------------------------------------- /ios/goodbyemoney/Images.xcassets/SplashScreenBackground.imageset/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/goodbyemoney-reactnative/HEAD/ios/goodbyemoney/Images.xcassets/SplashScreenBackground.imageset/image.png -------------------------------------------------------------------------------- /ios/goodbyemoney/AppDelegate.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import 4 | 5 | #import 6 | 7 | @interface AppDelegate : EXAppDelegateWrapper 8 | 9 | @end 10 | -------------------------------------------------------------------------------- /ios/goodbyemoney/main.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #import "AppDelegate.h" 4 | 5 | int main(int argc, char * argv[]) { 6 | @autoreleasepool { 7 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); 8 | } 9 | } 10 | 11 | -------------------------------------------------------------------------------- /realm.ts: -------------------------------------------------------------------------------- 1 | import { createRealmContext } from '@realm/react'; 2 | import { Category } from './models/category'; 3 | import { Expense } from './models/expense'; 4 | 5 | const config = { 6 | schema: [Category, Expense], 7 | }; 8 | export default createRealmContext(config); 9 | -------------------------------------------------------------------------------- /types/report-page.ts: -------------------------------------------------------------------------------- 1 | import { Expense } from '../models/expense'; 2 | import { Recurrence } from './recurrence'; 3 | 4 | export type ReportPageProps = { 5 | page: number; 6 | total: number; 7 | average: number; 8 | expenses: Expense[]; 9 | recurrence: Recurrence; 10 | }; 11 | -------------------------------------------------------------------------------- /app.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | expo: { 3 | slug: 'goodbyemoney', 4 | name: 'goodbyemoney', 5 | sdkVersion: '47.0.0', 6 | ios: { 7 | bundleIdentifier: 'com.nikolovlazar.goodbyemoney', 8 | supportsTablet: false, 9 | }, 10 | }, 11 | name: 'goodbyemoney', 12 | }; 13 | -------------------------------------------------------------------------------- /ios/goodbyemoney/goodbyemoney.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | 8 | -------------------------------------------------------------------------------- /ios/goodbyemoney.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/goodbyemoney.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /theme/index.ts: -------------------------------------------------------------------------------- 1 | export const theme = { 2 | dark: true, 3 | colors: { 4 | primary: '#0A84FF', 5 | background: '#000000', 6 | card: '#151515', 7 | text: '#FFFFFF', 8 | border: '#262629', 9 | notification: '#0A84FF', 10 | error: '#FF453A', 11 | textPrimary: '#FFFFFF', 12 | textSecondary: '#EBEBF599', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /utils/number.ts: -------------------------------------------------------------------------------- 1 | export const shortenNumber = (num: number) => { 2 | if (Number.isNaN(num)) { 3 | return 0; 4 | } 5 | if (num >= 1000000) { 6 | return (num / 1000000).toFixed(0).replace(/\.0$/, '') + 'M'; 7 | } 8 | if (num >= 1000) { 9 | return (num / 1000).toFixed(0).replace(/\.0$/, '') + 'K'; 10 | } 11 | return num.toFixed(0).toString(); 12 | }; 13 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import 'react-native-get-random-values'; 2 | 3 | import { registerRootComponent } from 'expo'; 4 | 5 | import App from './App'; 6 | 7 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App); 8 | // It also ensures that whether you load the app in Expo Go or in a native build, 9 | // the environment is set up appropriately 10 | registerRootComponent(App); 11 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "development-simulator": { 4 | "developmentClient": true, 5 | "distribution": "internal", 6 | "ios": { 7 | "simulator": true 8 | } 9 | }, 10 | "development": { 11 | "developmentClient": true, 12 | "distribution": "internal" 13 | }, 14 | "preview": { 15 | "distribution": "internal" 16 | }, 17 | "production": {} 18 | } 19 | } -------------------------------------------------------------------------------- /ios/goodbyemoney/Images.xcassets/SplashScreenBackground.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "idiom": "universal", 5 | "filename": "image.png", 6 | "scale": "1x" 7 | }, 8 | { 9 | "idiom": "universal", 10 | "scale": "2x" 11 | }, 12 | { 13 | "idiom": "universal", 14 | "scale": "3x" 15 | } 16 | ], 17 | "info": { 18 | "version": 1, 19 | "author": "expo" 20 | } 21 | } -------------------------------------------------------------------------------- /utils/recurrences.ts: -------------------------------------------------------------------------------- 1 | import { Recurrence } from '../types/recurrence'; 2 | 3 | export const getPlainRecurrence = (recurrence: Recurrence) => { 4 | switch (recurrence) { 5 | case Recurrence.Daily: 6 | return 'Day'; 7 | case Recurrence.Weekly: 8 | return 'Week'; 9 | case Recurrence.Monthly: 10 | return 'Month'; 11 | case Recurrence.Yearly: 12 | return 'Year'; 13 | default: 14 | return ''; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | project.xcworkspace 24 | .xcode.env.local 25 | 26 | # Bundle artifacts 27 | *.jsbundle 28 | 29 | # CocoaPods 30 | /Pods/ 31 | -------------------------------------------------------------------------------- /ios/.xcode.env: -------------------------------------------------------------------------------- 1 | # This `.xcode.env` file is versioned and is used to source the environment 2 | # used when running script phases inside Xcode. 3 | # To customize your local environment, you can create an `.xcode.env.local` 4 | # file that is not versioned. 5 | 6 | # NODE_BINARY variable contains the PATH to the node executable. 7 | # 8 | # Customize the NODE_BINARY variable here. 9 | # For example, to use nvm with brew, add the following line 10 | # . "$(brew --prefix nvm)/nvm.sh" --no-use 11 | export NODE_BINARY=$(command -v node) 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Goodbye Money 👋 2 | 3 | Goodbye Money is an Expense Tracking app built as part of my [live stream series](https://youtube.com/@nikolovlazar) that cover building the app in 4 different frameworks: 4 | 5 | 1. [SwiftUI](https://github.com/nikolovlazar/goodbyemoney-ios) 6 | 2. [Flutter](https://github.com/nikolovlazar/goodbyemoney-flutter) 7 | 3. [React Native](https://github.com/nikolovlazar/goodbyemoney-reactnative) (this repo) 8 | 4. [Android Native (Jetpack Compose)](https://github.com/nikolovlazar/goodbyemoney-jetpack-compose) 9 | 10 | This repo contains the React Native code. 11 | -------------------------------------------------------------------------------- /ios/goodbyemoney/Supporting/Expo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | EXUpdatesCheckOnLaunch 6 | ALWAYS 7 | EXUpdatesEnabled 8 | 9 | EXUpdatesLaunchWaitMs 10 | 0 11 | EXUpdatesSDKVersion 12 | 47.0.0 13 | EXUpdatesURL 14 | https://exp.host/@anonymous/goodbyemoney 15 | 16 | -------------------------------------------------------------------------------- /models/category.ts: -------------------------------------------------------------------------------- 1 | import Realm from 'realm'; 2 | 3 | export class Category extends Realm.Object { 4 | _id!: Realm.BSON.ObjectId; 5 | color: string; 6 | name: string; 7 | 8 | static generate(name: string, color: string) { 9 | return { 10 | _id: new Realm.BSON.ObjectId(), 11 | color, 12 | name, 13 | }; 14 | } 15 | // To use a class as a Realm object type, define the object schema on the static property "schema". 16 | static schema = { 17 | name: 'Category', 18 | primaryKey: '_id', 19 | properties: { 20 | _id: 'objectId', 21 | name: 'string', 22 | color: 'string', 23 | }, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /ios/goodbyemoney/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "29x29", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "29x29", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "40x40", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "40x40", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "60x60", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "60x60", 31 | "scale" : "3x" 32 | } 33 | ], 34 | "info" : { 35 | "version" : 1, 36 | "author" : "expo" 37 | } 38 | } -------------------------------------------------------------------------------- /components/TabBarIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MCI from '@expo/vector-icons/MaterialCommunityIcons'; 3 | import Ionicons from '@expo/vector-icons/Ionicons'; 4 | import ADI from '@expo/vector-icons/AntDesign'; 5 | 6 | type TabBarIconProps = { 7 | color: string; 8 | size: number; 9 | type: 'expenses' | 'reports' | 'add' | 'settings'; 10 | }; 11 | 12 | export const TabBarIcon = ({ type, color, size }: TabBarIconProps) => { 13 | switch (type) { 14 | case 'expenses': 15 | return ; 16 | case 'reports': 17 | return ; 18 | case 'add': 19 | return ; 20 | case 'settings': 21 | return ; 22 | default: 23 | return null; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /components/CategoryRow.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text } from 'react-native'; 2 | import { theme } from '../theme'; 3 | 4 | export const CategoryRow = ({ 5 | color, 6 | name, 7 | }: { 8 | color: string; 9 | name: string; 10 | }) => ( 11 | 24 | 34 | {name} 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /models/expense.ts: -------------------------------------------------------------------------------- 1 | import Realm from 'realm'; 2 | 3 | import { Recurrence } from '../types/recurrence'; 4 | import { Category } from './category'; 5 | 6 | export class Expense extends Realm.Object { 7 | _id!: Realm.BSON.ObjectId; 8 | amount: number; 9 | recurrence: string; 10 | date: Date; 11 | note: string; 12 | category: Category; 13 | 14 | static generate( 15 | amount: number, 16 | recurrence: Recurrence, 17 | date: Date, 18 | note: string, 19 | category: Category 20 | ) { 21 | return { 22 | _id: new Realm.BSON.ObjectId(), 23 | amount, 24 | recurrence: recurrence.toString(), 25 | date, 26 | note, 27 | category, 28 | }; 29 | } 30 | // To use a class as a Realm object type, define the object schema on the static property "schema". 31 | static schema = { 32 | name: 'Expense', 33 | primaryKey: '_id', 34 | properties: { 35 | _id: 'objectId', 36 | amount: 'int', 37 | recurrence: 'string', 38 | date: 'date', 39 | note: 'string', 40 | category: 'Category?', 41 | }, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "goodbyemoney", 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 | "@gorhom/bottom-sheet": "^4", 12 | "@react-native-community/datetimepicker": "6.5.2", 13 | "@react-navigation/bottom-tabs": "^6.4.1", 14 | "@react-navigation/native": "^6.0.14", 15 | "@react-navigation/native-stack": "^6.9.4", 16 | "@realm/react": "^0.4.1", 17 | "@sentry/react-native": "4.13.0", 18 | "@types/react": "~18.0.24", 19 | "@types/react-native": "~0.70.6", 20 | "d3": "^7.7.0", 21 | "date-fns": "^2.29.3", 22 | "expo": "~47.0.9", 23 | "expo-dev-client": "~2.0.1", 24 | "expo-splash-screen": "~0.17.5", 25 | "expo-status-bar": "~1.4.2", 26 | "expo-system-ui": "~2.0.1", 27 | "react": "18.1.0", 28 | "react-native": "0.70.5", 29 | "react-native-color-picker": "^0.6.0", 30 | "react-native-gesture-handler": "~2.8.0", 31 | "react-native-reanimated": "~2.12.0", 32 | "react-native-safe-area-context": "4.4.1", 33 | "react-native-screens": "~3.18.0", 34 | "react-native-svg": "^13.4.0", 35 | "realm": "^11.3.1", 36 | "typescript": "^4.6.3", 37 | "sentry-expo": "~6.0.0", 38 | "expo-application": "~5.0.1", 39 | "expo-constants": "~14.0.2", 40 | "expo-device": "~5.0.0", 41 | "expo-updates": "~0.15.6", 42 | "expo-network": "~5.0.0", 43 | "react-native-get-random-values": "~1.8.0" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.12.9" 47 | }, 48 | "private": true 49 | } 50 | -------------------------------------------------------------------------------- /utils/date.ts: -------------------------------------------------------------------------------- 1 | import { format, add, previousMonday, sub, nextSunday } from 'date-fns'; 2 | 3 | import { Recurrence } from '../types/recurrence'; 4 | 5 | export const formatDateRange = (start: Date, end: Date, period: Recurrence) => { 6 | switch (period) { 7 | case Recurrence.Weekly: 8 | return format(start, 'd MMM') + ' - ' + format(end, 'd MMM'); 9 | case Recurrence.Monthly: 10 | return format(start, 'MMMM'); 11 | case Recurrence.Yearly: 12 | return format(start, 'yyyy'); 13 | } 14 | 15 | return format(start, 'd MMM') + ' - ' + format(end, 'd MMM'); 16 | }; 17 | 18 | export const calculateRange = (period: Recurrence, periodIndex: number) => { 19 | const now = new Date(); 20 | let start: Date; 21 | let end: Date; 22 | 23 | switch (period) { 24 | case Recurrence.Daily: 25 | start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); 26 | end = add(start, { 27 | hours: 23, 28 | minutes: 59, 29 | seconds: 59, 30 | }); 31 | break; 32 | case Recurrence.Weekly: 33 | const firstDayOfThisWeek = previousMonday(now); 34 | const daysToSubtract = periodIndex * 7; 35 | start = sub(firstDayOfThisWeek, { days: daysToSubtract }); 36 | end = nextSunday(start); 37 | break; 38 | case Recurrence.Monthly: 39 | start = new Date(now.getFullYear(), now.getMonth() - periodIndex, 1); 40 | end = new Date(start.getFullYear(), start.getMonth() + 1, 0); 41 | break; 42 | case Recurrence.Yearly: 43 | start = new Date(now.getFullYear(), 0, 1); 44 | end = new Date(now.getFullYear(), 11, 31); 45 | break; 46 | } 47 | 48 | return { 49 | start, 50 | end, 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /screens/Settings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Alert } from 'react-native'; 3 | import Entypo from '@expo/vector-icons/Entypo'; 4 | 5 | import { ListItem } from '../components/ListItem'; 6 | import { theme } from '../theme'; 7 | import RealmContext from '../realm'; 8 | 9 | const { useRealm } = RealmContext; 10 | 11 | export const Settings = ({ navigation }) => { 12 | const realm = useRealm(); 13 | 14 | return ( 15 | 22 | 31 | } 32 | onClick={() => { 33 | navigation.navigate('Categories'); 34 | }} 35 | /> 36 | { 40 | Alert.alert( 41 | 'Are you sure?', 42 | 'This action cannot be undone', 43 | [ 44 | { 45 | text: 'Cancel', 46 | onPress: () => {}, 47 | style: 'cancel', 48 | }, 49 | { 50 | text: 'Erase data', 51 | style: 'destructive', 52 | onPress: () => { 53 | realm.write(() => { 54 | realm.deleteAll(); 55 | }); 56 | }, 57 | }, 58 | ], 59 | { 60 | userInterfaceStyle: 'dark', 61 | } 62 | ); 63 | }} 64 | /> 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /components/ExpenseRow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text } from 'react-native'; 3 | import { theme } from '../theme'; 4 | 5 | import { Expense } from '../models/expense'; 6 | 7 | type Props = { 8 | expense: Expense; 9 | }; 10 | 11 | export const ExpenseRow = ({ expense }: Props) => ( 12 | 13 | 23 | 30 | {expense.note} 31 | 32 | 39 | USD {expense.amount} 40 | 41 | 42 | 51 | 59 | 60 | {expense.category.name} 61 | 62 | 63 | 64 | {`${expense.date.getHours()}`.padStart(2, '0')}: 65 | {`${expense.date.getMinutes()}`.padStart(2, '0')} 66 | 67 | 68 | 69 | ); 70 | -------------------------------------------------------------------------------- /components/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Text, TouchableOpacity } from 'react-native'; 3 | import Swipeable from 'react-native-gesture-handler/Swipeable'; 4 | import { theme } from '../theme'; 5 | 6 | type Props = { 7 | label: string; 8 | detail?: React.ReactNode; 9 | onClick?: () => void; 10 | swipeToDelete?: boolean; 11 | onDelete?: () => void; 12 | isDestructive?: boolean; 13 | }; 14 | 15 | export const ListItem = ({ 16 | label, 17 | detail, 18 | onClick, 19 | swipeToDelete, 20 | onDelete, 21 | isDestructive, 22 | }: Props) => { 23 | const item = useMemo( 24 | () => ( 25 | 41 | 47 | {label} 48 | 49 | {detail} 50 | 51 | ), 52 | [label, detail] 53 | ); 54 | if (swipeToDelete) { 55 | return ( 56 | ( 58 | 66 | Delete 67 | 68 | )} 69 | onSwipeableRightOpen={onDelete} 70 | > 71 | {item} 72 | 73 | ); 74 | } 75 | return item; 76 | }; 77 | -------------------------------------------------------------------------------- /screens/Home.tsx: -------------------------------------------------------------------------------- 1 | import { TouchableOpacity } from 'react-native'; 2 | import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; 3 | import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; 4 | import BottomSheet from '@gorhom/bottom-sheet'; 5 | 6 | import { Expenses, Reports, Add, Settings } from '../screens'; 7 | import { TabBarIcon } from '../components/TabBarIcon'; 8 | import { theme } from '../theme'; 9 | import React, { useRef } from 'react'; 10 | 11 | const Tab = createBottomTabNavigator(); 12 | 13 | export const Home = () => { 14 | const reportsSheetRef = useRef(null); 15 | 16 | return ( 17 | 24 | , 27 | }} 28 | name='Expenses' 29 | component={Expenses} 30 | /> 31 | , 34 | headerRight: () => ( 35 | reportsSheetRef.current.expand()} 37 | style={{ marginRight: 16 }} 38 | > 39 | 44 | 45 | ), 46 | }} 47 | name='Reports' 48 | > 49 | {() => } 50 | 51 | , 54 | }} 55 | name='Add' 56 | component={Add} 57 | /> 58 | , 61 | }} 62 | name='Settings' 63 | component={Settings} 64 | /> 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /components/ExpensesList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text, FlatList } from 'react-native'; 3 | import { theme } from '../theme'; 4 | 5 | import { ExpensesGroup } from '../types/expenses-group'; 6 | import { ExpenseRow } from './ExpenseRow'; 7 | 8 | type Props = { 9 | groups: ExpensesGroup[]; 10 | }; 11 | 12 | export const ExpensesList = ({ groups }: Props) => ( 13 | item.day} 17 | renderItem={({ item: { day, expenses, total } }) => ( 18 | 21 | 29 | {day} 30 | 31 | 38 | {expenses.map((expense) => ( 39 | 40 | ))} 41 | 48 | 57 | 63 | Total: 64 | 65 | 72 | USD {total} 73 | 74 | 75 | 76 | )} 77 | /> 78 | ); 79 | -------------------------------------------------------------------------------- /utils/expenses.ts: -------------------------------------------------------------------------------- 1 | import { format, isThisYear, isToday, isYesterday } from 'date-fns'; 2 | 3 | import { Expense } from '../models/expense'; 4 | import { ExpensesGroup } from '../types/expenses-group'; 5 | import { Recurrence } from '../types/recurrence'; 6 | import { calculateRange } from './date'; 7 | 8 | export const filterExpensesInPeriod = ( 9 | expenses: Expense[], 10 | period: Recurrence, 11 | periodIndex: number 12 | ) => { 13 | const { start, end } = calculateRange(period, periodIndex); 14 | 15 | return expenses.filter((expense) => { 16 | const { date } = expense; 17 | return date >= start && date <= end; 18 | }); 19 | }; 20 | 21 | export const groupExpensesByDay = (expenses: Expense[]): ExpensesGroup[] => { 22 | const groupedExpenses: { [key: string]: Expense[] } = {}; 23 | 24 | expenses.sort((a, b) => { 25 | return b.date.getTime() - a.date.getTime(); 26 | }); 27 | 28 | expenses.forEach((expense) => { 29 | const { date } = expense; 30 | let key = ''; 31 | if (isToday(date)) { 32 | key = 'Today'; 33 | } else if (isYesterday(date)) { 34 | key = 'Yesterday'; 35 | } else if (isThisYear(date)) { 36 | key = format(date, 'E, d MMM'); 37 | } else { 38 | key = format(date, 'E, d MMM yyyy'); 39 | } 40 | 41 | if (!groupedExpenses[key]) { 42 | groupedExpenses[key] = []; 43 | } 44 | 45 | groupedExpenses[key].push(expense); 46 | }); 47 | 48 | return Object.keys(groupedExpenses).map((key) => ({ 49 | day: key, 50 | expenses: groupedExpenses[key], 51 | total: groupedExpenses[key].reduce( 52 | (acc, expense) => acc + expense.amount, 53 | 0 54 | ), 55 | })); 56 | }; 57 | 58 | export const getGroupedExpenses = ( 59 | expenses: Expense[], 60 | recurrence: Recurrence 61 | ) => { 62 | const filteredExpenses = filterExpensesInPeriod( 63 | Array.from(expenses), 64 | recurrence, 65 | 0 66 | ); 67 | 68 | return groupExpensesByDay(filteredExpenses); 69 | }; 70 | 71 | export const getAverageAmountInPeriod = (total: number, period: Recurrence) => { 72 | switch (period) { 73 | case Recurrence.Weekly: 74 | return total / 7; 75 | case Recurrence.Monthly: 76 | return total / 30; 77 | case Recurrence.Yearly: 78 | return total / 365; 79 | default: 80 | return total; 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking") 2 | require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods") 3 | require File.join(File.dirname(`node --print "require.resolve('@react-native-community/cli-platform-ios/package.json')"`), "native_modules") 4 | 5 | require 'json' 6 | podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} 7 | 8 | platform :ios, podfile_properties['ios.deploymentTarget'] || '13.0' 9 | install! 'cocoapods', 10 | :deterministic_uuids => false 11 | 12 | target 'goodbyemoney' do 13 | use_expo_modules! 14 | config = use_native_modules! 15 | 16 | use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] 17 | 18 | # Flags change depending on the env values. 19 | flags = get_default_flags() 20 | 21 | use_react_native!( 22 | :path => config[:reactNativePath], 23 | :hermes_enabled => podfile_properties['expo.jsEngine'] == 'hermes', 24 | :fabric_enabled => flags[:fabric_enabled], 25 | # An absolute path to your application root. 26 | :app_path => "#{Pod::Config.instance.installation_root}/..", 27 | # 28 | # Uncomment to opt-in to using Flipper 29 | # Note that if you have use_frameworks! enabled, Flipper will not work 30 | # :flipper_configuration => !ENV['CI'] ? FlipperConfiguration.enabled : FlipperConfiguration.disabled, 31 | ) 32 | 33 | post_install do |installer| 34 | react_native_post_install( 35 | installer, 36 | # Set `mac_catalyst_enabled` to `true` in order to apply patches 37 | # necessary for Mac Catalyst builds 38 | :mac_catalyst_enabled => false 39 | ) 40 | __apply_Xcode_12_5_M1_post_install_workaround(installer) 41 | 42 | # This is necessary for Xcode 14, because it signs resource bundles by default 43 | # when building for devices. 44 | installer.target_installation_results.pod_target_installation_results 45 | .each do |pod_name, target_installation_result| 46 | target_installation_result.resource_bundle_targets.each do |resource_bundle_target| 47 | resource_bundle_target.build_configurations.each do |config| 48 | config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' 49 | end 50 | end 51 | end 52 | end 53 | 54 | post_integrate do |installer| 55 | begin 56 | expo_patch_react_imports!(installer) 57 | rescue => e 58 | Pod::UI.warn e 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBar } from 'expo-status-bar'; 2 | import * as Network from 'expo-network'; 3 | import { NavigationContainer } from '@react-navigation/native'; 4 | import { createNativeStackNavigator } from '@react-navigation/native-stack'; 5 | import * as Sentry from 'sentry-expo'; 6 | 7 | import { theme } from './theme'; 8 | import { Categories } from './screens/Categories'; 9 | import { Home } from './screens/Home'; 10 | import RealmContext from './realm'; 11 | import { useRef } from 'react'; 12 | 13 | const routingInstrumentation = 14 | new Sentry.Native.ReactNavigationInstrumentation(); 15 | 16 | const devServerPort = 8081; 17 | let devServerIpAddress: string | null = null; 18 | Network.getIpAddressAsync().then((ip) => { 19 | devServerIpAddress = ip; 20 | }); 21 | 22 | Sentry.init({ 23 | dsn: 'https://4d8e522ac187444fa51215c63949cc74@o1418292.ingest.sentry.io/4504486326370304', 24 | // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. 25 | // We recommend adjusting this value in production. 26 | tracesSampleRate: 1.0, 27 | enableInExpoDevelopment: true, 28 | enableAutoPerformanceTracking: true, 29 | enableAutoSessionTracking: true, 30 | // @ts-ignore 31 | attachScreenshot: true, 32 | 33 | integrations: [ 34 | new Sentry.Native.ReactNativeTracing({ 35 | // Pass instrumentation to be used as `routingInstrumentation` 36 | routingInstrumentation, 37 | shouldCreateSpanForRequest: (url) => { 38 | return ( 39 | !__DEV__ || 40 | !url.startsWith(`http://${devServerIpAddress}:${devServerPort}/logs`) 41 | ); 42 | }, 43 | }), 44 | ], 45 | }); 46 | 47 | const Stack = createNativeStackNavigator(); 48 | const { RealmProvider } = RealmContext; 49 | 50 | function App() { 51 | const navigation = useRef(); 52 | return ( 53 | 54 | { 58 | // Register the navigation container with the instrumentation 59 | routingInstrumentation.registerNavigationContainer(navigation); 60 | }} 61 | > 62 | 63 | 64 | 69 | 70 | 71 | 72 | 73 | ); 74 | } 75 | 76 | export default Sentry.Native.wrap(App); 77 | -------------------------------------------------------------------------------- /ios/goodbyemoney/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | goodbyemoney 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0.0 21 | CFBundleSignature 22 | ???? 23 | CFBundleURLTypes 24 | 25 | 26 | CFBundleURLSchemes 27 | 28 | com.nikolovlazar.goodbyemoney 29 | 30 | 31 | 32 | CFBundleURLSchemes 33 | 34 | exp+goodbyemoney 35 | 36 | 37 | 38 | CFBundleVersion 39 | 1 40 | LSRequiresIPhoneOS 41 | 42 | NSAppTransportSecurity 43 | 44 | NSAllowsArbitraryLoads 45 | 46 | NSExceptionDomains 47 | 48 | localhost 49 | 50 | NSExceptionAllowsInsecureHTTPLoads 51 | 52 | 53 | 54 | 55 | UILaunchStoryboardName 56 | SplashScreen 57 | UIRequiredDeviceCapabilities 58 | 59 | armv7 60 | 61 | UIRequiresFullScreen 62 | 63 | UIStatusBarStyle 64 | UIStatusBarStyleDefault 65 | UISupportedInterfaceOrientations 66 | 67 | UIInterfaceOrientationPortrait 68 | UIInterfaceOrientationPortraitUpsideDown 69 | UIInterfaceOrientationLandscapeLeft 70 | UIInterfaceOrientationLandscapeRight 71 | 72 | UISupportedInterfaceOrientations~ipad 73 | 74 | UIInterfaceOrientationPortrait 75 | UIInterfaceOrientationPortraitUpsideDown 76 | UIInterfaceOrientationLandscapeLeft 77 | UIInterfaceOrientationLandscapeRight 78 | 79 | UIUserInterfaceStyle 80 | Light 81 | UIViewControllerBasedStatusBarAppearance 82 | 83 | 84 | -------------------------------------------------------------------------------- /ios/goodbyemoney.xcodeproj/xcshareddata/xcschemes/goodbyemoney.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /ios/goodbyemoney/SplashScreen.storyboard: -------------------------------------------------------------------------------- 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 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /components/charts/MonthlyChart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Svg, G, Rect, Text, Line } from 'react-native-svg'; 3 | import { Dimensions } from 'react-native'; 4 | import * as d3 from 'd3'; 5 | 6 | import { shortenNumber } from '../../utils/number'; 7 | import { theme } from '../../theme'; 8 | import { Expense } from '../../models/expense'; 9 | 10 | type Props = { 11 | expenses: Expense[]; 12 | date: Date; 13 | }; 14 | 15 | const GRAPH_MARGIN = 11; 16 | const GRAPH_BAR_WIDTH = 8; 17 | 18 | export const MonthlyChart = ({ expenses, date }: Props) => { 19 | const numOfDaysInMonth = new Date( 20 | date.getFullYear(), 21 | date.getMonth() + 1, 22 | 0 23 | ).getDate(); 24 | let averageExpense = 0; 25 | const defaultValues = Array.from({ length: numOfDaysInMonth }, (_, i) => ({ 26 | day: i + 1, 27 | total: 0, 28 | })); 29 | 30 | const groupedExpenses = expenses.reduce((acc, expense) => { 31 | averageExpense += expense.amount; 32 | const day = expense.date.getDate(); 33 | const existing = acc.find((e) => e.day === day); 34 | if (!!existing) { 35 | existing.total += expense.amount; 36 | return acc; 37 | } 38 | acc.push({ 39 | day, 40 | total: expense.amount, 41 | }); 42 | return acc; 43 | }, defaultValues); 44 | averageExpense = averageExpense / expenses.length; 45 | 46 | const SVGHeight = 147 + 2 * GRAPH_MARGIN; 47 | const SVGWidth = Dimensions.get('window').width; 48 | const graphHeight = SVGHeight - 2 * GRAPH_MARGIN; 49 | const graphWidth = SVGWidth - 2 * GRAPH_MARGIN; 50 | 51 | // x scale point 52 | const xDomain = groupedExpenses.map((expense) => expense.day); 53 | const xRange = [35, graphWidth]; 54 | const x = d3.scalePoint().domain(xDomain).range(xRange).padding(2); 55 | 56 | // y scale point 57 | const maxValue = d3.max(groupedExpenses, (e) => e.total); 58 | const yDomain = [0, maxValue]; 59 | const yRange = [0, graphHeight]; 60 | const y = d3.scaleLinear().domain(yDomain).range(yRange); 61 | 62 | return ( 63 | 64 | 65 | {groupedExpenses.map((item, index) => ( 66 | 67 | 75 | 83 | {index % 5 === 0 && ( 84 | 90 | {item.day} 91 | 92 | )} 93 | 94 | ))} 95 | 102 | 0 103 | 104 | 111 | {shortenNumber(averageExpense)} 112 | 113 | 120 | {shortenNumber(maxValue)} 121 | 122 | 130 | 131 | 132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /screens/Expenses.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { Text, View, TouchableOpacity, TouchableHighlight } from 'react-native'; 3 | import * as Sentry from 'sentry-expo'; 4 | 5 | import RealmContext from '../realm'; 6 | import { ExpensesList } from '../components/ExpensesList'; 7 | import { theme } from '../theme'; 8 | import { Recurrence } from '../types/recurrence'; 9 | import { Expense } from '../models/expense'; 10 | import { getGroupedExpenses } from '../utils/expenses'; 11 | import BottomSheet, { BottomSheetFlatList } from '@gorhom/bottom-sheet'; 12 | import { getPlainRecurrence } from '../utils/recurrences'; 13 | 14 | const { useQuery } = RealmContext; 15 | 16 | export const Expenses = () => { 17 | const expenses = useQuery(Expense); 18 | const recurrenceSheetRef = useRef(); 19 | const [recurrence, setRecurrence] = React.useState(Recurrence.Weekly); 20 | 21 | const groupedExpenses = getGroupedExpenses(Array.from(expenses), recurrence); 22 | const total = groupedExpenses.reduce((sum, group) => (sum += group.total), 0); 23 | 24 | const changeRecurrence = (newRecurrence: Recurrence) => { 25 | setRecurrence(newRecurrence); 26 | recurrenceSheetRef.current?.close(); 27 | }; 28 | 29 | return ( 30 | <> 31 | 41 | 51 | 52 | Total for: 53 | 54 | recurrenceSheetRef.current?.expand()} 57 | > 58 | 59 | This {getPlainRecurrence(recurrence)} 60 | 61 | 62 | 63 | 73 | 80 | $ 81 | 82 | 90 | {total} 91 | 92 | 93 | 94 | 95 | 107 | ( 116 | changeRecurrence(item)} 119 | > 120 | 127 | This {getPlainRecurrence(item)} 128 | 129 | 130 | )} 131 | /> 132 | 133 | 134 | ); 135 | }; 136 | -------------------------------------------------------------------------------- /components/charts/WeeklyChart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Svg, G, Rect, Text, Line } from 'react-native-svg'; 3 | import { Dimensions } from 'react-native'; 4 | import * as d3 from 'd3'; 5 | 6 | import { shortenNumber } from '../../utils/number'; 7 | import { theme } from '../../theme'; 8 | import { Expense } from '../../models/expense'; 9 | 10 | type Props = { 11 | expenses: Expense[]; 12 | }; 13 | 14 | const GRAPH_MARGIN = 16; 15 | const GRAPH_BAR_WIDTH = 39; 16 | 17 | const dayNumberNames = { 18 | 0: 'Sunday', 19 | 1: 'Monday', 20 | 2: 'Tuesday', 21 | 3: 'Wednesday', 22 | 4: 'Thursday', 23 | 5: 'Friday', 24 | 6: 'Saturday', 25 | }; 26 | 27 | export const WeeklyChart = ({ expenses }: Props) => { 28 | let averageExpense = 0; 29 | const defaultValues = [ 30 | { 31 | day: 'Monday', 32 | total: 0, 33 | }, 34 | { 35 | day: 'Tuesday', 36 | total: 0, 37 | }, 38 | { 39 | day: 'Wednesday', 40 | total: 0, 41 | }, 42 | { 43 | day: 'Thursday', 44 | total: 0, 45 | }, 46 | { 47 | day: 'Friday', 48 | total: 0, 49 | }, 50 | { 51 | day: 'Saturday', 52 | total: 0, 53 | }, 54 | { 55 | day: 'Sunday', 56 | total: 0, 57 | }, 58 | ]; 59 | 60 | const groupedExpenses = expenses.reduce((acc, expense) => { 61 | averageExpense += expense.amount; 62 | const day = dayNumberNames[expense.date.getDay()]; 63 | const existing = acc.find((e) => e.day === day); 64 | if (!!existing) { 65 | existing.total += expense.amount; 66 | return acc; 67 | } 68 | acc.push({ 69 | day, 70 | total: expense.amount, 71 | }); 72 | return acc; 73 | }, defaultValues); 74 | averageExpense = averageExpense / 7; 75 | 76 | const SVGHeight = 147 + 2 * GRAPH_MARGIN; 77 | const SVGWidth = Dimensions.get('window').width; 78 | const graphHeight = SVGHeight - 2 * GRAPH_MARGIN; 79 | const graphWidth = SVGWidth - 2 * GRAPH_MARGIN; 80 | 81 | // x scale point 82 | const xDomain = groupedExpenses.map((expense) => expense.day); 83 | const xRange = [0, graphWidth]; 84 | const x = d3.scalePoint().domain(xDomain).range(xRange).padding(1); 85 | 86 | // y scale point 87 | const maxValue = d3.max(groupedExpenses, (e) => e.total); 88 | const yDomain = [0, maxValue]; 89 | const yRange = [0, graphHeight]; 90 | const y = d3.scaleLinear().domain(yDomain).range(yRange); 91 | 92 | return ( 93 | 94 | 95 | {groupedExpenses.map((item) => ( 96 | 97 | 105 | 113 | 119 | {item.day[0]} 120 | 121 | 122 | ))} 123 | 130 | 0 131 | 132 | 139 | {shortenNumber(averageExpense)} 140 | 141 | 148 | {shortenNumber(maxValue)} 149 | 150 | 158 | 159 | 160 | ); 161 | }; 162 | -------------------------------------------------------------------------------- /components/ReportPage.tsx: -------------------------------------------------------------------------------- 1 | import { View, Dimensions, Text } from 'react-native'; 2 | import * as Sentry from 'sentry-expo'; 3 | 4 | import { theme } from '../theme'; 5 | import { Recurrence } from '../types/recurrence'; 6 | import { type ReportPageProps } from '../types/report-page'; 7 | import { calculateRange, formatDateRange } from '../utils/date'; 8 | import { groupExpensesByDay } from '../utils/expenses'; 9 | import { shortenNumber } from '../utils/number'; 10 | import { MonthlyChart } from './charts/MonthlyChart'; 11 | import { WeeklyChart } from './charts/WeeklyChart'; 12 | import { YearlyChart } from './charts/YearlyChart'; 13 | import { ExpensesList } from './ExpensesList'; 14 | 15 | export const ReportPage = Sentry.Native.withProfiler( 16 | ({ page, total, average, expenses, recurrence }: ReportPageProps) => { 17 | const groupedExpenses = groupExpensesByDay(expenses); 18 | 19 | const { start, end } = calculateRange(recurrence, page); 20 | const periodLabel = formatDateRange(start, end, recurrence); 21 | 22 | return ( 23 | 32 | 39 | 40 | 41 | {periodLabel} 42 | 43 | 50 | 56 | USD 57 | 58 | 66 | {shortenNumber(total)} 67 | 68 | 69 | 70 | 77 | 78 | Avg/Day 79 | 80 | 87 | 93 | USD 94 | 95 | 103 | {shortenNumber(average)} 104 | 105 | 106 | 107 | 108 | {expenses.length > 0 ? ( 109 | <> 110 | 111 | {recurrence === Recurrence.Weekly && ( 112 | 113 | )} 114 | {recurrence === Recurrence.Monthly && ( 115 | 116 | )} 117 | {recurrence === Recurrence.Yearly && ( 118 | 119 | )} 120 | 121 | 122 | 123 | 124 | 125 | ) : ( 126 | 135 | There are no expenses reported for this period. 136 | 137 | )} 138 | 139 | ); 140 | } 141 | ); 142 | -------------------------------------------------------------------------------- /screens/Reports.tsx: -------------------------------------------------------------------------------- 1 | import BottomSheet, { BottomSheetFlatList } from '@gorhom/bottom-sheet'; 2 | import React, { MutableRefObject, useReducer, useRef } from 'react'; 3 | import { Text, TouchableHighlight, FlatList, Dimensions } from 'react-native'; 4 | import { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; 5 | 6 | import RealmContext from '../realm'; 7 | import { theme } from '../theme'; 8 | import { Recurrence } from '../types/recurrence'; 9 | import { Expense } from '../models/expense'; 10 | import { 11 | filterExpensesInPeriod, 12 | getAverageAmountInPeriod, 13 | } from '../utils/expenses'; 14 | import { ReportPageProps } from '../types/report-page'; 15 | import { ReportPage } from '../components/ReportPage'; 16 | 17 | const { useQuery } = RealmContext; 18 | 19 | type Props = { 20 | reportsSheetRef: MutableRefObject; 21 | }; 22 | 23 | enum PagerReducerActionTypes { 24 | SET_RECURRENCE = 'SET_RECURRENCE', 25 | } 26 | 27 | type PagerReducerAction = { 28 | type: PagerReducerActionTypes; 29 | payload: any; 30 | }; 31 | 32 | type PagerState = { 33 | recurrence: Recurrence; 34 | numberOfPages: number; 35 | }; 36 | 37 | function pagerReducer(state: PagerState, action: PagerReducerAction) { 38 | switch (action.type) { 39 | case PagerReducerActionTypes.SET_RECURRENCE: 40 | var newNumberOfPages = 1; 41 | switch (action.payload) { 42 | case Recurrence.Weekly: 43 | newNumberOfPages = 53; 44 | break; 45 | case Recurrence.Monthly: 46 | newNumberOfPages = 12; 47 | break; 48 | case Recurrence.Yearly: 49 | newNumberOfPages = 1; 50 | break; 51 | } 52 | 53 | return { 54 | ...state, 55 | recurrence: action.payload, 56 | numberOfPages: newNumberOfPages, 57 | page: 0, 58 | }; 59 | default: 60 | return state; 61 | } 62 | } 63 | 64 | export const Reports = ({ reportsSheetRef }: Props) => { 65 | const expenses = useQuery(Expense); 66 | const listRef = useRef(null); 67 | 68 | const [state, dispatch] = useReducer(pagerReducer, { 69 | recurrence: Recurrence.Weekly, 70 | numberOfPages: 53, 71 | }); 72 | 73 | const selectRecurrence = (selectedRecurrence: Recurrence) => { 74 | dispatch({ 75 | type: PagerReducerActionTypes.SET_RECURRENCE, 76 | payload: selectedRecurrence, 77 | }); 78 | reportsSheetRef.current.close(); 79 | listRef.current.scrollToIndex({ index: 0 }); 80 | }; 81 | 82 | const data = Array.from({ 83 | length: state.numberOfPages, 84 | }).map((_, page) => { 85 | const filteredExpenses = filterExpensesInPeriod( 86 | Array.from(expenses), 87 | state.recurrence, 88 | page 89 | ); 90 | 91 | const total = 92 | filteredExpenses.reduce((acc, expense) => acc + expense.amount, 0) ?? 0; 93 | const average = getAverageAmountInPeriod(total, state.recurrence); 94 | 95 | return { 96 | page, 97 | total, 98 | average, 99 | expenses: filteredExpenses, 100 | recurrence: state.recurrence, 101 | }; 102 | }); 103 | 104 | return ( 105 | <> 106 | } 116 | /> 117 | 129 | ( 133 | selectRecurrence(item)} 136 | > 137 | 145 | {item} 146 | 147 | 148 | )} 149 | /> 150 | 151 | 152 | ); 153 | }; 154 | -------------------------------------------------------------------------------- /components/charts/YearlyChart.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Svg, G, Rect, Text, Line } from 'react-native-svg'; 3 | import { Dimensions } from 'react-native'; 4 | import * as d3 from 'd3'; 5 | 6 | import { shortenNumber } from '../../utils/number'; 7 | import { theme } from '../../theme'; 8 | import { Expense } from '../../models/expense'; 9 | 10 | type Props = { 11 | expenses: Expense[]; 12 | }; 13 | 14 | const GRAPH_MARGIN = 15; 15 | const GRAPH_BAR_WIDTH = 20; 16 | 17 | export const YearlyChart = ({ expenses }: Props) => { 18 | let averageExpense = 0; 19 | 20 | const defaultValues = [ 21 | { 22 | month: 'January', 23 | total: 0, 24 | }, 25 | { 26 | month: 'February', 27 | total: 0, 28 | }, 29 | { 30 | month: 'March', 31 | total: 0, 32 | }, 33 | { 34 | month: 'April', 35 | total: 0, 36 | }, 37 | { 38 | month: 'May', 39 | total: 0, 40 | }, 41 | { 42 | month: 'June', 43 | total: 0, 44 | }, 45 | { 46 | month: 'July', 47 | total: 0, 48 | }, 49 | { 50 | month: 'August', 51 | total: 0, 52 | }, 53 | { 54 | month: 'September', 55 | total: 0, 56 | }, 57 | { 58 | month: 'October', 59 | total: 0, 60 | }, 61 | { 62 | month: 'November', 63 | total: 0, 64 | }, 65 | { 66 | month: 'December', 67 | total: 0, 68 | }, 69 | ]; 70 | 71 | const monthNumberNames = defaultValues.map((e) => e.month); 72 | 73 | const groupedExpenses = expenses.reduce((acc, expense) => { 74 | averageExpense += expense.amount; 75 | const month = monthNumberNames[expense.date.getMonth()]; 76 | const existing = acc.find((e) => e.month === month); 77 | if (!!existing) { 78 | existing.total += expense.amount; 79 | return acc; 80 | } 81 | acc.push({ 82 | month, 83 | total: expense.amount, 84 | }); 85 | return acc; 86 | }, defaultValues); 87 | averageExpense = averageExpense / expenses.length; 88 | 89 | const SVGHeight = 147 + 2 * GRAPH_MARGIN; 90 | const SVGWidth = Dimensions.get('window').width; 91 | const graphHeight = SVGHeight - 3 * GRAPH_MARGIN; 92 | const graphWidth = SVGWidth - 2 * GRAPH_MARGIN - 40; 93 | 94 | // x scale point 95 | const xDomain = groupedExpenses.map((expense) => expense.month); 96 | const xRange = [65, graphWidth]; 97 | const x = d3.scalePoint().domain(xDomain).range(xRange).padding(-0.75); 98 | 99 | // y scale point 100 | const maxValue = d3.max(groupedExpenses, (e) => e.total); 101 | const yDomain = [0, maxValue]; 102 | const yRange = [0, graphHeight]; 103 | const y = d3.scaleLinear().domain(yDomain).range(yRange); 104 | 105 | return ( 106 | 107 | 108 | {groupedExpenses.map((item) => ( 109 | 110 | 118 | 126 | 136 | {item.month.substring(0, 3)} 137 | 138 | 139 | ))} 140 | 147 | 0 148 | 149 | 156 | {shortenNumber(averageExpense)} 157 | 158 | 165 | {shortenNumber(maxValue)} 166 | 167 | 175 | 176 | 177 | ); 178 | }; 179 | -------------------------------------------------------------------------------- /screens/Categories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Button, 4 | KeyboardAvoidingView, 5 | Modal, 6 | ScrollView, 7 | TextInput, 8 | View, 9 | } from 'react-native'; 10 | import { BSON } from 'realm'; 11 | import Swipeable from 'react-native-gesture-handler/Swipeable'; 12 | import FontAwesome from '@expo/vector-icons/FontAwesome'; 13 | import { ColorPicker, fromHsv } from 'react-native-color-picker'; 14 | import EvilIcons from '@expo/vector-icons/EvilIcons'; 15 | 16 | import RealmContext from '../realm'; 17 | import { theme } from '../theme'; 18 | import { RectButton, TouchableOpacity } from 'react-native-gesture-handler'; 19 | import { CategoryRow } from '../components/CategoryRow'; 20 | import { Category } from '../models/category'; 21 | 22 | const { useQuery, useRealm } = RealmContext; 23 | 24 | export const Categories = () => { 25 | const realm = useRealm(); 26 | const categories = useQuery(Category); 27 | 28 | const [showColorPicker, setShowColorPicker] = useState(false); 29 | const [selectedColor, setSelectedColor] = useState(theme.colors.primary); 30 | const [newName, setNewName] = useState(''); 31 | 32 | const onSelectColor = (hex: string) => { 33 | setSelectedColor(hex); 34 | }; 35 | 36 | const createCategory = () => { 37 | if (newName.length === 0) { 38 | return; 39 | } 40 | 41 | realm.write(() => { 42 | realm.create('Category', Category.generate(newName, selectedColor)); 43 | }); 44 | 45 | setNewName(''); 46 | setSelectedColor(theme.colors.primary); 47 | }; 48 | 49 | const deleteCategory = (id: BSON.ObjectId) => { 50 | realm.write(() => { 51 | const category = realm.objectForPrimaryKey('Category', id); 52 | realm.delete(category); 53 | }); 54 | }; 55 | 56 | return ( 57 | <> 58 | 63 | 64 | 70 | {categories.map(({ _id, color, name }) => ( 71 | { 74 | return ( 75 | 81 | deleteCategory(_id)} 88 | > 89 | 90 | 91 | 92 | ); 93 | }} 94 | > 95 | 96 | 97 | ))} 98 | 99 | 100 | 108 | setShowColorPicker(!showColorPicker)} 110 | > 111 | 121 | 122 | setNewName(event.nativeEvent.text)} 126 | value={newName} 127 | style={{ 128 | color: 'white', 129 | height: 40, 130 | borderColor: theme.colors.border, 131 | borderWidth: 1, 132 | flex: 1, 133 | borderRadius: 8, 134 | paddingLeft: 8, 135 | marginLeft: 16, 136 | }} 137 | /> 138 | 144 | 145 | 146 | 147 | 148 | 149 | setShowColorPicker(false)} 154 | > 155 | 165 | 176 | onSelectColor(fromHsv(color))} 180 | style={{ width: '100%', height: 300 }} 181 | /> 182 |