├── assets
├── icon.png
├── favicon.png
├── splash.png
└── adaptive-icon.png
├── tsconfig.json
├── babel.config.js
├── .env.example
├── constants
├── expenses.ts
└── firebase.ts
├── utils
└── getRandomColor.ts
├── .gitignore
├── app.json
├── package.json
├── components
├── Header.tsx
└── Expense.tsx
└── App.tsx
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CallumHemsley/Offline-First-Expo-Demo-with-Legend-State/HEAD/assets/icon.png
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CallumHemsley/Offline-First-Expo-Demo-with-Legend-State/HEAD/assets/favicon.png
--------------------------------------------------------------------------------
/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CallumHemsley/Offline-First-Expo-Demo-with-Legend-State/HEAD/assets/splash.png
--------------------------------------------------------------------------------
/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CallumHemsley/Offline-First-Expo-Demo-with-Legend-State/HEAD/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | EXPO_PUBLIC_FIREBASE_API_KEY=
2 | EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=
3 | EXPO_PUBLIC_FIREBASE_DATABASE_URL=
4 | EXPO_PUBLIC_FIREBASE_PROJECT_ID=
5 | EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=
6 | EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
7 | EXPO_PUBLIC_FIREBASE_APP_ID=
8 |
--------------------------------------------------------------------------------
/constants/expenses.ts:
--------------------------------------------------------------------------------
1 | export const randomExpenseNames = [
2 | "Groceries",
3 | "Netflix",
4 | "Gas",
5 | "Dinner",
6 | "Electricity",
7 | "Shoes",
8 | "Coffee",
9 | "Gym",
10 | "Toiletries",
11 | "Train Ticket",
12 | "Water Bill",
13 | "Internet",
14 | "Parking",
15 | "Cinema",
16 | "Lunch",
17 | "Book",
18 | "Taxi",
19 | "Laundry",
20 | "Gift",
21 | "Insurance",
22 | ];
23 |
--------------------------------------------------------------------------------
/utils/getRandomColor.ts:
--------------------------------------------------------------------------------
1 | export const getRandomPastelColor = () => {
2 | const baseLightness = 200; // Base value to ensure lightness
3 | const range = 56; // Range for variation (255 - 200)
4 |
5 | const r = Math.floor(Math.random() * range) + baseLightness;
6 | const g = Math.floor(Math.random() * range) + baseLightness;
7 | const b = Math.floor(Math.random() * range) + baseLightness;
8 |
9 | return `rgb(${r}, ${g}, ${b})`;
10 | };
11 |
--------------------------------------------------------------------------------
/constants/firebase.ts:
--------------------------------------------------------------------------------
1 | export const firebaseConfig = {
2 | apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY,
3 | authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
4 | databaseURL: process.env.EXPO_PUBLIC_FIREBASE_DATABASE_URL,
5 | projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID,
6 | storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
7 | messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
8 | appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
9 | };
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 | .env
34 |
35 | # typescript
36 | *.tsbuildinfo
37 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "local-first-finance",
4 | "slug": "local-first-finance",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "splash": {
10 | "image": "./assets/splash.png",
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "assetBundlePatterns": [
15 | "**/*"
16 | ],
17 | "ios": {
18 | "supportsTablet": true
19 | },
20 | "android": {
21 | "adaptiveIcon": {
22 | "foregroundImage": "./assets/adaptive-icon.png",
23 | "backgroundColor": "#ffffff"
24 | }
25 | },
26 | "web": {
27 | "favicon": "./assets/favicon.png"
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "local-first-finance",
3 | "version": "1.0.0",
4 | "main": "node_modules/expo/AppEntry.js",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo start --android",
8 | "ios": "expo start --ios",
9 | "web": "expo start --web"
10 | },
11 | "dependencies": {
12 | "@legendapp/state": "^2.1.3",
13 | "@react-native-async-storage/async-storage": "^1.19.6",
14 | "expo": "~50.0.4",
15 | "expo-status-bar": "~1.11.1",
16 | "firebase": "10.1.0",
17 | "react": "18.2.0",
18 | "react-native": "0.73.2",
19 | "react-native-reanimated": "~3.6.2"
20 | },
21 | "devDependencies": {
22 | "@babel/core": "^7.20.0",
23 | "@types/react": "~18.2.45",
24 | "typescript": "^5.1.3"
25 | },
26 | "private": true
27 | }
28 |
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, Text, View } from "react-native";
2 | import React from "react";
3 |
4 | const Header = () => {
5 | return (
6 |
7 | House Expenses
8 |
9 | );
10 | };
11 |
12 | const styles = StyleSheet.create({
13 | headerContainer: {
14 | backgroundColor: "#ffffff",
15 | borderBottomLeftRadius: 30,
16 | borderBottomRightRadius: 30,
17 | paddingTop: 30,
18 | paddingBottom: 30,
19 | paddingHorizontal: 20,
20 | alignItems: "center",
21 | justifyContent: "center",
22 | display: "flex",
23 | },
24 | headerTitle: {
25 | color: "#007AFF",
26 | fontSize: 24,
27 | fontWeight: "bold",
28 | },
29 | });
30 |
31 | export default Header;
32 |
--------------------------------------------------------------------------------
/components/Expense.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, Text, View } from "react-native";
2 | import Animated, {
3 | SlideInDown,
4 | useAnimatedStyle,
5 | useSharedValue,
6 | withTiming,
7 | } from "react-native-reanimated";
8 | import React, { useEffect } from "react";
9 |
10 | const Expense = ({ item }) => {
11 | const opacity = useSharedValue(1);
12 |
13 | // Set up the flashing effect on render
14 | useEffect(() => {
15 | // Flash to 0.2 opacity then back to 1
16 | opacity.value = withTiming(0.2, { duration: 100 }, () => {
17 | opacity.value = withTiming(1, { duration: 200 });
18 | });
19 | }, []);
20 |
21 | // Animated style that uses the shared opacity value
22 | const animatedStyle = useAnimatedStyle(() => {
23 | return {
24 | opacity: opacity.value,
25 | };
26 | });
27 |
28 | return (
29 |
33 |
36 |
37 | {item.title}
38 | {item.date}
39 |
40 | ${item.amount.toFixed(2)}
41 |
42 | );
43 | };
44 |
45 | const styles = StyleSheet.create({
46 | expenseItem: {
47 | flexDirection: "row",
48 | alignItems: "center",
49 | paddingVertical: 10,
50 | paddingHorizontal: 15,
51 | marginHorizontal: 20,
52 | marginVertical: 4, // smaller gap between expenses
53 | backgroundColor: "#FFFFFF", // light background color
54 | borderRadius: 10,
55 | shadowColor: "#000",
56 | shadowOffset: { width: 0, height: 1 },
57 | shadowOpacity: 0.2, // subtler shadow
58 | shadowRadius: 2,
59 | elevation: 2, // for Android shadow
60 | },
61 | expenseColorBlock: {
62 | width: 50,
63 | height: 50,
64 | borderRadius: 10,
65 | marginRight: 15,
66 | },
67 | expenseDetails: {
68 | flex: 1,
69 | justifyContent: "space-between",
70 | },
71 | expenseTitle: {
72 | fontSize: 16,
73 | fontWeight: "bold",
74 | color: "#333333",
75 | },
76 | expenseDate: {
77 | marginTop: 5,
78 | fontSize: 14,
79 | color: "#555555",
80 | },
81 | expenseAmount: {
82 | fontSize: 16,
83 | color: "#555555",
84 | fontWeight: "bold",
85 | },
86 | });
87 |
88 | export default Expense;
89 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import { Button, StyleSheet, View, FlatList } from "react-native";
2 | import { StatusBar } from "expo-status-bar";
3 | import { observable } from "@legendapp/state";
4 | import {
5 | configureObservablePersistence,
6 | persistObservable,
7 | } from "@legendapp/state/persist";
8 | import { ObservablePersistFirebase } from "@legendapp/state/persist-plugins/firebase";
9 | import { ObservablePersistAsyncStorage } from "@legendapp/state/persist-plugins/async-storage";
10 | import AsyncStorage from "@react-native-async-storage/async-storage";
11 | import { observer } from "@legendapp/state/react";
12 | import { initializeApp } from "firebase/app";
13 | import Expense from "./components/Expense";
14 | import { getRandomPastelColor } from "./utils/getRandomColor";
15 | import Header from "./components/Header";
16 | import { randomExpenseNames } from "./constants/expenses";
17 | import { firebaseConfig } from "./constants/firebase";
18 |
19 | // Initialize Firebase
20 | initializeApp(firebaseConfig);
21 |
22 | configureObservablePersistence({
23 | // Use AsyncStorage in React Native
24 | pluginLocal: ObservablePersistAsyncStorage,
25 | localOptions: {
26 | asyncStorage: {
27 | // The AsyncStorage plugin needs to be given the implementation of AsyncStorage
28 | AsyncStorage,
29 | },
30 | },
31 | });
32 |
33 | const state = observable({
34 | expenses: [
35 | {
36 | id: "1",
37 | title: "Groceries",
38 | amount: 50.0,
39 | color: getRandomPastelColor(),
40 | date: new Date().toLocaleString(),
41 | },
42 | {
43 | id: "2",
44 | title: "Electric Bill",
45 | amount: 75.0,
46 | color: getRandomPastelColor(),
47 | date: new Date().toLocaleString(),
48 | },
49 | ],
50 | });
51 |
52 | persistObservable(state, {
53 | local: "persist-demo",
54 | pluginRemote: ObservablePersistFirebase,
55 | remote: {
56 | onSetError: (err: unknown) => console.error(err),
57 | firebase: {
58 | refPath: () => `/expenses/`,
59 | mode: "realtime",
60 | },
61 | },
62 | });
63 |
64 | const App = observer(() => {
65 | const expenses = state.expenses.get();
66 |
67 | const addExpense = () => {
68 | const expenseIndex = expenses.length % randomExpenseNames.length;
69 | const newExpense = {
70 | id: Math.random().toString(),
71 | title: randomExpenseNames[expenseIndex],
72 | amount: Math.floor(Math.random() * 100),
73 | color: getRandomPastelColor(),
74 | date: new Date().toLocaleString(),
75 | };
76 | state.expenses.set((currentExpenses) => [...currentExpenses, newExpense]);
77 | };
78 |
79 | return (
80 |
81 |
82 |
83 | item.id}
86 | renderItem={({ item }) => }
87 | />
88 |
89 |
90 | );
91 | });
92 |
93 | const styles = StyleSheet.create({
94 | container: {
95 | flex: 1,
96 | paddingTop: 50,
97 | paddingBottom: 50,
98 | },
99 | });
100 |
101 | export default App;
102 |
--------------------------------------------------------------------------------