I am developing a React native app for Android which will change some cell values of a Google sheet. It worked well before I made significant changes to incorporate async-storage
and AuthContext
.
My app is structured like this:
app/index.tsx:
import { useState } from "react";
import {
Image,
View,
Text,
TextInput,
StyleSheet,
Alert,
TouchableOpacity,
} from "react-native";
import { useRouter } from "expo-router";
import { useAuth } from "@/context/AuthContext";
import { FontAwesome } from "@expo/vector-icons";
import Feather from "@expo/vector-icons/Feather";
const Index = () => {
const router = useRouter();
const { login } = useAuth();
const [username, setUsername] = useState(""); // State for username
const [password, setPassword] = useState(""); // State for password
const [showPassword, setShowPassword] = useState(false);
const handleLogin = async () => {
try {
// Make POST request to your server with the username and password
const response = await fetch(
";,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
}
);
if (response.ok) {
const data = await response.json();
const { token } = data;
// On successful login, store the token and navigate
login(token); // Save token using context
router.replace("/home"); // Navigate to the home screen
} else {
Alert.alert("Login Failed", "Invalid username or password");
}
} catch (error) {
Alert.alert("Login Failed", "An error occurred during login");
}
};
return (
<View style={styles.container}>
<Image
style={styles.image}
source={require("@/assets/adaptive-icon.png")}
/>
<Text style={styles.title}>Welcome to Expense Tracker!</Text>
{/* Username Input */}
<View style={styles.fieldContainer}>
<Feather style={styles.icons} name="user" size={24} color="black" />
<TextInput
style={styles.input}
placeholder="Username"
value={username}
onChangeText={(text) => setUsername(text)}
/>
</View>
{/* Password Input */}
<View style={styles.fieldContainer}>
<Feather style={styles.icons} name="lock" size={20} color="black" />
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword} // Toggle visibility
/>
<TouchableOpacity
style={styles.eyeIcon}
onPress={() => setShowPassword(!showPassword)}
>
<FontAwesome
name={showPassword ? "eye-slash" : "eye"}
size={20}
color="gray"
/>
</TouchableOpacity>
</View>
{/* Login Button */}
{/* <Button title="Login" onPress={handleLogin} /> */}
<TouchableOpacity style={styles.button} onPress={handleLogin}>
<Text style={styles.buttonText}>Login</Text>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
button: {
width: "100%",
height: 50,
backgroundColor: "#1E90FF",
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
marginBottom: 20,
},
buttonText: {
color: "#fff",
fontSize: 18,
},
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
},
eyeIcon: {
position: "absolute",
right: 10,
top: "50%",
transform: [{ translateY: -17 }], // Center vertically
},
fieldContainer: {
flexDirection: "row",
alignItems: "center",
width: "100%",
},
icons: {
position: "absolute",
left: 10,
top: "50%",
transform: [{ translateY: -19 }], // Center vertically
},
input: {
width: "100%",
padding: 10,
paddingLeft: 40,
borderWidth: 1,
borderColor: "gray",
borderRadius: 5,
marginBottom: 15,
},
image: {
width: 200,
height: 200,
resizeMode: "contain",
},
title: { fontSize: 24, fontWeight: "bold", marginBottom: 20 },
});
export default Index;
app/_layout.tsx:
import { Stack } from "expo-router/stack";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { ActivityIndicator, View } from "react-native";
export default function RootLayout() {
return (
<AuthProvider>
<MainNavigator />
</AuthProvider>
);
}
function MainNavigator() {
const { isLoggedIn, isLoading } = useAuth();
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" color="#1E90FF" />
</View>
); // Render a loading spinner or blank screen
}
return (
<Stack screenOptions={{ headerShown: false }}>
{!isLoggedIn ? (
<Stack.Screen name="index" />
) : (
<Stack.Screen name="(tabs)" />
)}
</Stack>
);
}
app/(tabs)/_layout.tsx:
import FontAwesome from "@expo/vector-icons/FontAwesome";
import AntDesign from "@expo/vector-icons/AntDesign";
import { Tabs, useRouter } from "expo-router";
export default function TabLayout() {
const router = useRouter(); // Access router for navigation
return (
<Tabs screenOptions={{ tabBarActiveTintColor: "blue" }}>
<Tabs.Screen
name="home"
options={{
title: "Home",
headerShown: false,
tabBarIcon: ({ color }) => (
<FontAwesome size={24} name="home" color={color} />
),
}}
/>
<Tabs.Screen
name="journal"
options={{
title: "Journal",
headerShown: false,
tabBarIcon: ({ color }) => (
<AntDesign size={24} name="form" color={color} />
),
}}
/>
<Tabs.Screen
name="logout"
options={{
title: "Logout",
headerShown: false,
tabBarIcon: ({ color }) => (
<AntDesign size={24} name="logout" color={color} />
),
}}
/>
</Tabs>
);
}
app/tabs/home.tsx:
import { useEffect, useState } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
Pressable,
Alert,
ActivityIndicator,
StatusBar,
} from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { MaterialIcons } from "@expo/vector-icons";
import FontAwesome6 from "@expo/vector-icons/FontAwesome6";
import BankNoteCard from "@/components/BankNoteCard";
import CustomButton from "@/components/CustomButton";
export default function Home() {
const apiEndpoint = ";;
const [bankNotes, setBankNotes] = useState<number[]>([]);
const [quantities, setQuantities] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
// Function to fetch the data from the server
const fetchData = async () => {
const token = await AsyncStorage.getItem("authToken");
if (!token) {
Alert.alert("Please log in first!");
return;
}
setLoading(true); // Set loading state to true
try {
const response = await fetch(`${apiEndpoint}/fetchQuantities`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const { bankNotes: fetchedBankNotes, quantities: fetchedQuantities } =
await response.json();
setBankNotes(fetchedBankNotes || []); // Set the bankNotes from the response
setQuantities(
fetchedQuantities && fetchedQuantities.length
? fetchedQuantities
: fetchedBankNotes.map(() => 0)
);
} else {
Alert.alert("Failed to fetch quantities", "You may need to log in.");
}
} catch (error) {
console.error("Error fetching data:", error);
Alert.alert("Error", "Failed to fetch data. Please try again.");
} finally {
setLoading(false); // Set loading state to false after fetching is complete
}
};
// Fetch data from the backend server on mount
useEffect(() => {
fetchData();
}, []);
// Sync button handler to manually trigger data fetch
const handleSync = () => {
fetchData();
};
const handleResetAll = () => {
setQuantities(bankNotes.map(() => 0));
};
const handleUpdateQuantity = (index: number, newQuantity: number) => {
setQuantities((prevQuantities) => {
const updatedQuantities = [...prevQuantities];
updatedQuantities[index] = newQuantity;
return updatedQuantities;
});
};
const handleUpdate = async () => {
const token = await AsyncStorage.getItem("authToken");
try {
await fetch(`${apiEndpoint}/updateQuantities`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ quantities }),
});
// Show alert if update is successful
Alert.alert("Success!", "Quantities updated successfully.", [
{ text: "Ok" },
]);
} catch (error) {
console.error("Error updating quantities:", error);
// Show error alert if update fails
Alert.alert("Error", "Failed to update quantities. Please try again.", [
{ text: "OK" },
]);
}
};
return (
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<View style={styles.headerContainer}>
<Text style={styles.heading}>Cash in Hand</Text>
<View style={styles.sync}>
<Pressable
style={({ pressed }) => [{ opacity: pressed ? 0.2 : 1 }]}
onPress={handleSync}
>
<MaterialIcons name="sync" size={24} />
</Pressable>
</View>
</View>
{loading ? (
<View style={styles.initialLoader}>
<ActivityIndicator size="large" color="green" />
</View>
) : (
<>
<FlatList
data={bankNotes}
keyExtractor={(item) => item.toString()}
renderItem={({ item, index }) => (
<BankNoteCard
note={item}
quantity={quantities[index]}
onUpdateQuantity={(newQuantity) =>
handleUpdateQuantity(index, newQuantity)
}
/>
)}
/>
<Text style={styles.total}>
Total amount:{" "}
<FontAwesome6
name="bangladeshi-taka-sign"
size={styles.total.fontSize}
color="black"
/>{" "}
{bankNotes
.reduce(
(sum, note, index) => (sum += note * quantities[index]),
0
)
.toLocaleString()}
</Text>
<View style={styles.buttonContainer}>
<CustomButton
handlePress={handleResetAll}
title="Reset all"
buttonStyle={{ backgroundColor: "red" }}
/>
<CustomButton handlePress={handleUpdate} title="Update" />
</View>
</>
)}
<StatusBar backgroundColor={"green"} barStyle={"light-content"} />
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
buttonContainer: {
flexDirection: "row",
gap: 5,
},
container: {
flex: 1,
padding: 10,
},
headerContainer: {
flex: 1,
},
heading: {
fontSize: 20,
alignSelf: "center",
fontWeight: "bold",
},
initialLoader: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
},
sync: {
position: "absolute",
right: 20,
top: 20,
},
total: {
fontSize: 18,
fontWeight: "bold",
},
});
app/(tabs)/journal.tsx:
import { StyleSheet, Text, View } from "react-native";
export default function Journal() {
return (
<View>
<Text>Journal</Text>
</View>
);
}
const styles = StyleSheet.create({});
app/(tabs)/logout.tsx:
import { View, Text, Button, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useAuth } from "@/context/AuthContext"; // Assuming you have an AuthContext
export default function Logout() {
const { logout } = useAuth(); // Access the logout function from AuthContext
const router = useRouter(); // Access router for navigation
// Handle logout and navigation when the button is pressed
const handleLogout = () => {
logout(); // Log the user out
router.replace("/"); // Navigate to the welcome screen
};
return (
<View style={styles.container}>
<Text style={styles.title}>Are you sure you want to log out?</Text>
<Button title="Logout" onPress={handleLogout} color="red" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center", // Center the content vertically
alignItems: "center", // Center the content horizontally
backgroundColor: "#fff", // Optional: Set a background color
},
title: {
fontSize: 18,
marginBottom: 20, // Add space above the button
color: "#000", // Optional: Set text color
},
});
context/AuthContext.tsx:
import {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; // AsyncStorage import
import { ActivityIndicator, View } from "react-native";
// Define the type for the AuthContext
interface AuthContextType {
isLoggedIn: boolean;
isLoading: boolean;
login: (token: string) => void;
logout: () => void;
}
// Define the type for the children prop
interface AuthProviderProps {
children: ReactNode; // Use ReactNode type for children
}
// Create the AuthContext
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Check if the user is logged in when the app starts
useEffect(() => {
const checkLoginState = async () => {
try {
const token = await AsyncStorage.getItem("authToken");
if (token) {
setIsLoggedIn(true); // User is logged in if token exists
}
} catch (error) {
console.log("Error checking login state", error);
} finally {
setIsLoading(false);
}
};
checkLoginState();
}, []);
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" color="#1E90FF" />
</View>
);
}
const login = async (token: string) => {
await AsyncStorage.setItem("authToken", token); // Store token
setIsLoggedIn(true);
};
const logout = async () => {
await AsyncStorage.removeItem("authToken"); // Remove token
setIsLoggedIn(false);
};
return (
<AuthContext.Provider value={{ isLoggedIn, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
server/server.js:
//require("dotenv").config({ path: "../.env.local" });
const express = require("express");
const { GoogleSpreadsheet } = require("google-spreadsheet");
const { JWT } = require("google-auth-library");
const jwt = require("jsonwebtoken");
const app = express();
const port = process.env.PORT || 3000;
const validUsername = process.env.VALID_USERNAME;
const validPassword = process.env.VALID_PASSWORD;
// Middleware to check if the user is authenticated
const authenticateToken = (req, res, next) => {
const token = req.headers["authorization"]?.split(" ")[1]; // Extract token from Authorization header
if (!token) {
return res.status(401).send("Access Denied: No token provided");
}
jwt.verify(token, process.env.SECRET_KEY, (err, user) => {
// Use the environment variable
if (err) {
return res.status(403).send("Access Denied: Invalid token");
}
req.user = user; // Attach user info to the request object
next(); // Proceed to the next middleware or route handler
});
};
// Google Sheets API setup
const SPREADSHEET_ID = process.env.SPREADSHEET_ID;
const SHEET_TITLE = process.env.SHEET_TITLE;
const SCOPES = [
";,
".file",
];
app.post("/login", express.json(), (req, res) => {
const { username, password } = req.body;
// Check credentials
if (username === validUsername && password === validPassword) {
// Create a JWT token with a secret key
const token = jwt.sign({ username }, process.env.SECRET_KEY, {
expiresIn: "1h",
});
return res.json({ token }); // Send token to frontend
} else {
return res.status(401).send("Invalid credentials");
}
});
// Fetch initial quantities from Google Sheets
app.get("/fetchQuantities", authenticateToken, async (req, res) => {
try {
// Authenticate with Google Sheets API using the service account
const jwt = new JWT({
email: process.env.CLIENT_EMAIL,
key: process.env.PRIVATE_KEY.replace(/\\n/g, "\n"),
scopes: SCOPES,
});
const doc = new GoogleSpreadsheet(SPREADSHEET_ID, jwt);
await doc.loadInfo();
const sheet = doc.sheetsByTitle[SHEET_TITLE];
const rows = await sheet.getRows();
// Map rows to quantities (assuming column 'Qty' contains quantities)
const bankNotes = rows.map((row) => parseInt(row._rawData[0]) || "0", 10);
const quantities = rows.map((row) => parseInt(row._rawData[1] || "0", 10));
res.json({ bankNotes, quantities });
} catch (error) {
console.error("Error fetching quantities:", error);
res.status(500).send("Error fetching data from Google Sheets");
}
});
// Update quantities in Google Sheets (assuming there's a 'Qty' column)
app.post(
"/updateQuantities",
authenticateToken,
express.json(),
async (req, res) => {
try {
const { quantities } = req.body; // An array of quantities
if (!Array.isArray(quantities)) {
return res.status(400).send("Invalid data format");
}
// Authenticate and access Google Sheets
const jwt = new JWT({
email: process.env.CLIENT_EMAIL,
key: process.env.PRIVATE_KEY.replace(/\\n/g, "\n"),
scopes: SCOPES,
});
const doc = new GoogleSpreadsheet(SPREADSHEET_ID, jwt);
await doc.loadInfo();
const sheet = doc.sheetsByTitle[SHEET_TITLE];
const rows = await sheet.getRows();
// Update rows based on the quantities
rows.forEach(async (row, index) => {
row._rawData[1] = quantities[index] || 0;
await row.save(); // Ensure the save operation completes
});
res.status(200).send("Quantities updated successfully");
} catch (error) {
console.error("Error updating quantities:", error);
res.status(500).send("Error updating data in Google Sheets");
}
}
);
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
app.json
{
"expo": {
"name": "Expense Tracker",
"slug": "expense-g45",
"version": "1.0.5",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.myname.expense-g45"
},
"web": {
"favicon": "./assets/favicon.png"
},
"extra": {
"eas": {
"projectId": "po092345234-e461-48d2-9a7b-sdfbdte2342"
}
},
"newArchEnabled": true,
"scheme": "expensetracker",
"plugins": [
"expo-router"
]
}
}
eas.json
{
"build": {
"preview": {
"android": {
"buildType": "apk"
},
"autoIncrement": true
},
"preview2": {
"android": {
"gradleCommand": ":app:assembleRelease"
}
},
"preview3": {
"developmentClient": true
},
"preview4": {
"distribution": "internal"
},
"production": {}
},
"cli": {
"appVersionSource": "remote"
}
}
package.json
{
"name": "expense-g45",
"version": "1.0.5",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^14.0.4",
"@react-native-async-storage/async-storage": "1.23.1",
"dotenv": "^16.4.7",
"expo": "~52.0.26",
"expo-router": "~4.0.17",
"expo-status-bar": "~2.0.1",
"express": "^4.21.1",
"google-auth-library": "^9.14.2",
"google-spreadsheet": "^4.1.4",
"jsonwebtoken": "^9.0.2",
"react": "18.3.1",
"react-native": "0.76.6",
"react-native-safe-area-context": "4.12.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~18.3.12",
"nodemon": "^3.1.9",
"typescript": "^5.3.3"
},
"private": true,
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"listUnknownPackages": false
}
}
}
}