├── 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 |