Expense Capture and Dashboard App

Build a cross-platform expense app with camera, JWT auth, and charts.

Introduction

30 Second Summary

Every time you snap a photo of a receipt, your phone is doing something powerful. It turns a physical document into digital data that can be analyzed, categorized, and visualized.

In this project, you will build a cross-platform expense tracking app that runs on your iPhone, Android phone, and web browser from a single codebase. The app connects to your existing Spring Boot backend to capture invoices with your camera and display spending analytics with interactive charts.

What You'll Build

A React Native app built with Expo that captures invoice photos, authenticates against a JWT-secured backend, and displays spending analytics with interactive charts.

By the end of this project, you'll have:

  • A login screen that authenticates against your Spring Boot JWT endpoint and securely stores the token.
  • A camera screen that captures invoice photos and uploads them to your backend OCR processing pipeline.
  • A spending dashboard with a pie chart (spending by category) and a bar chart (monthly totals) pulling real data from your backend analytics APIs.
  • Secret Mission: Add an interactive monthly line chart with category filtering and date range selection.

Are there any prerequisites?

This project assumes you have a running Spring Boot backend with JWT authentication endpoints, invoice processing endpoints, and analytics/spending APIs. You should also have a basic understanding of REST APIs and JSON.

You will need an iPhone and an Android phone to test the app on physical devices. A code editor like VS Code is also required.

Before We Start

Before We Start...

Install Node.js and Create Your Expo Project

Your Spring Boot backend is already handling the heavy lifting: JWT authentication, invoice OCR, and spending analytics.

Now you need a frontend that can run on your iPhone, Android phone, and web browser from a single codebase. In this step, you'll install Node.js and create your Expo project so you can see a live app on both of your phones within minutes.

In this step, get ready to:
  • Install Node.js LTS on your machine.
  • Create an Expo project with a single terminal command.
  • Run the app live on your iPhone and Android phone using Expo Go.
Install Node.js

Expo runs on Node.js, the JavaScript runtime that powers your development server and build tools.

macOS

  • Press Cmd+Space to open Spotlight Search.
  • Type Terminal and press Enter to open it.
  • Install Node.js using Homebrew by running this command:
brew install node

What is Homebrew?

Homebrew is a package manager for macOS that installs developer tools with a single command. If you don't have Homebrew installed yet, visit brew.sh and run the install command shown on that page first.

Windows

  • Open your browser and go to nodejs.org/en/download.
  • Click the LTS tab and download the Windows Installer (.msi) for your system.
  • Run the downloaded installer and follow the prompts to complete the installation.
  • Press the Windows key, type Command Prompt, and press Enter to open it.
  • Verify Node.js installed successfully by running this command:
node --version

✔️ I see a version number

Node.js is installed and ready to go.

ⓧ Command not found

Node.js didn't install correctly. Close and reopen your terminal, then try the command again.

On macOS, make sure Homebrew itself is installed (run brew --version to check). On Windows, rerun the installer and make sure the "Add to PATH" checkbox was selected.

  • Also verify npm (the Node package manager) is available by running:
npm --version

You should see a version number printed in the terminal. npm is installed automatically alongside Node.js.

Create your Expo project

Expo is the officially recommended way to build React Native apps. One codebase targets iOS, Android, and web simultaneously.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a new Expo project by running this command:
npx create-expo-app@latest

What happens when you run this?

The command will ask you for a project name. Type expense-tracker and press Enter.

This creates an SDK 54 project, which is compatible with Expo Go on physical devices. It also includes Expo Router for file-based navigation and TypeScript out of the box.

Wait for the installation to complete. You'll see a success message once all dependencies are downloaded.

  • Move into your new project folder by running:
cd expense-tracker
  • Install expo-secure-store, which you'll use to securely store JWT tokens on your devices, by running:
npx expo install expo-secure-store

Why npx expo install instead of npm install?

The npx expo install command automatically picks the version of the package that is compatible with your Expo SDK version. This prevents version mismatches that can cause hard-to-debug errors.

  • Confirm the project was created by running:
ls

You should see app/, package.json, tsconfig.json, and other scaffold files listed in the output.

What's in your project?

The app/ directory is where all your screens live. Expo Router uses the file structure inside this folder to automatically create navigation routes.

The app/_layout.tsx file is the root layout that wraps every screen. You'll replace it in the next step with your own authentication provider.

Command not working or install failed?

Make sure you ran cd ~/Desktop before creating the project. If you see a permission error, try running the command with sudo on macOS or running your terminal as Administrator on Windows.

Help me debug my Expo project creation error

Run the app on your phones with Expo Go

Expo Go is a free app that loads your project directly onto your phone over WiFi. Every time you save a file, the app updates instantly on your device.

  • On your iPhone, open the App Store and search for Expo Go.
  • Tap Get to install it.
  • On your Android phone, open the Google Play Store and search for Expo Go.
  • Tap Install to download it.

Same WiFi network required

Your phone and your computer must be connected to the same WiFi network for Expo Go to reach the development server.

  • Open your project folder in VS Code (or your preferred code editor).

macOS

  • Press Cmd+Space to open Spotlight Search.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the expense-tracker folder, and click Open.

Windows

  • Press the Windows key to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the expense-tracker folder, and click Select Folder.
  • Open a terminal inside VS Code by pressing Ctrl+` (backtick key, usually above Tab).
  • Start the Expo development server by running:
npx expo start

After a few seconds, you'll see a QR code printed in your terminal along with a list of options.

  • On your iPhone, open the default Camera app and point it at the QR code in your terminal.
  • Tap the notification banner that appears to open the project in Expo Go.
  • On your Android phone, open the Expo Go app directly.
  • Tap Scan QR code and point your camera at the QR code in your terminal.

You should see the default Expo welcome screen appear on both phones. This confirms your development environment is working end to end.

QR code not connecting?

Make sure your phone and computer are on the same WiFi network. If you're on a corporate or university network that blocks local traffic, try using a mobile hotspot instead.

If the connection still fails, press s in the terminal to switch to Expo Go mode, or try pressing w to open the app in your web browser as a fallback.

Help me connect Expo Go to my dev server

Your development environment is set up and your app is running live on both devices. Next up, you'll build a login screen that authenticates against your Spring Boot backend.

Build the Login Screen with JWT Authentication

Your Expo app is running on both phones and in the browser. But right now it has no way to identify who's using it.

Your Spring Boot backend requires a JWT token with every request. That means the first thing your frontend needs is a way to log in, receive a token, and store it securely on device. In this step, you'll build an authentication system using React Context and expo-secure-store.

In this step, get ready to:
  • Create an authentication context that manages JWT tokens across iOS, Android, and web.
  • Build a login form that sends credentials to your Spring Boot backend.
  • Wire the auth context into your app layout so every screen can access the session.
Create the authentication context

React Context lets you share data (like the logged-in user's token) across every screen without passing it manually through props. You'll create a context that handles signing in, signing out, and persisting the session token.

  • In your project's root folder (the same level as the app/ directory), create a new folder called context.
  • Inside context/, create a new file called AuthContext.tsx.
  • Add the following code to define the auth interface and cross-platform storage helper:
import { createContext, useContext, useEffect, useCallback, useReducer } from 'react';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';

// Define what the auth system provides to the rest of the app
type AuthContextType = {
  signIn: (email: string, password: string) => Promise<void>;
  signOut: () => void;
  session: string | null;
  isLoading: boolean;
};

// Create the context with default values
const AuthContext = createContext<AuthContextType>({
  signIn: async () => {},
  signOut: () => {},
  session: null,
  isLoading: true,
});

// Convenience hook for other components to access auth state
export function useSession() {
  return useContext(AuthContext);
}

// Cross-platform storage: SecureStore on mobile, localStorage on web
async function setStorageItemAsync(key: string, value: string | null) {
  if (Platform.OS === 'web') {
    if (value === null) {
      localStorage.removeItem(key);
    } else {
      localStorage.setItem(key, value);
    }
  } else {
    if (value === null) {
      await SecureStore.deleteItemAsync(key);
    } else {
      await SecureStore.setItemAsync(key, value);
    }
  }
}

What does this code do?

AuthContextType defines the four things any screen can access: the signIn and signOut functions, the current session token, and a loading flag.

The useSession() hook is a shortcut so components can access auth state with a single line.

The setStorageItemAsync helper uses the device's secure storage (iOS Keychain, Android Keystore) on phones and falls back to localStorage in the browser.

  • Save AuthContext.tsx.
  • You won't see a change yet. The next chunk adds the state management logic.
  • Below the setStorageItemAsync function, add the state management logic:
// Types for our auth state machine
type State = { isLoading: boolean; session: string | null };
type Action = { type: 'RESTORE_TOKEN'; token: string | null } | { type: 'SIGN_IN'; token: string } | { type: 'SIGN_OUT' };

// Reducer handles all possible state transitions
function authReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'RESTORE_TOKEN':
      return { isLoading: false, session: action.token };
    case 'SIGN_IN':
      return { isLoading: false, session: action.token };
    case 'SIGN_OUT':
      return { isLoading: false, session: null };
    default:
      return state;
  }
}

What does this code do?

The reducer handles three state transitions. RESTORE_TOKEN fires when the app loads a previously saved token. SIGN_IN stores a new token after a successful login. SIGN_OUT clears the session.

  • Save the file. The next chunk builds the main provider component that uses this reducer.
  • Below the authReducer function, add the SessionProvider component:
const API_BASE_URL = 'http://localhost:8080';

export function SessionProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, { isLoading: true, session: null });

  // On app start, check if we already have a saved token
  useEffect(() => {
    async function loadToken() {
      let token: string | null = null;
      if (Platform.OS === 'web') {
        token = localStorage.getItem('session');
      } else {
        token = await SecureStore.getItemAsync('session');
      }
      dispatch({ type: 'RESTORE_TOKEN', token });
    }
    loadToken();
  }, []);

  // Send credentials to backend and store the returned JWT
  const signIn = useCallback(async (email: string, password: string) => {
    const response = await fetch(`${API_BASE_URL}/api/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
    if (!response.ok) {
      throw new Error('Invalid credentials');
    }
    const data = await response.json();
    const token = data.token;
    await setStorageItemAsync('session', token);
    dispatch({ type: 'SIGN_IN', token });
  }, []);

  // Clear the stored token and reset state
  const signOut = useCallback(async () => {
    await setStorageItemAsync('session', null);
    dispatch({ type: 'SIGN_OUT' });
  }, []);

  return (
    <AuthContext.Provider value={{ signIn, signOut, session: state.session, isLoading: state.isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}

What does this code do?

The useEffect runs when the app opens. It checks secure storage for a previously saved token so users stay logged in between sessions.

The signIn function POSTs credentials to your Spring Boot /api/auth/login endpoint, saves the returned JWT token to secure storage, and dispatches SIGN_IN to update the app state.

The signOut function deletes the stored token and resets the session to null.

Testing on a physical phone

The API_BASE_URL is set to http://localhost:8080 which works when running in the web browser. For testing on your physical phone, you'll need to replace localhost with your computer's local IP address (e.g. http://192.168.1.x:8080) so the phone can reach the backend over WiFi.

  • Save context/AuthContext.tsx.
  • You won't see a visual change yet. The context needs to be wired into the app layout before it takes effect.

Seeing red squiggly lines in your editor?

TypeScript errors at this point are expected if your editor can't resolve the imports yet. They'll clear up once the file is saved and your dev server picks it up.

If you see an error on the SecureStore import, confirm expo-secure-store is listed in your package.json dependencies. It was installed in Step 1.

Why is my SecureStore import not resolving?

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { createContext, useContext, useEffect, useCallback, useReducer } from 'react';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';

type AuthContextType = {
  signIn: (email: string, password: string) => Promise<void>;
  signOut: () => void;
  session: string | null;
  isLoading: boolean;
};

const AuthContext = createContext<AuthContextType>({
  signIn: async () => {},
  signOut: () => {},
  session: null,
  isLoading: true,
});

export function useSession() {
  return useContext(AuthContext);
}

async function setStorageItemAsync(key: string, value: string | null) {
  if (Platform.OS === 'web') {
    if (value === null) {
      localStorage.removeItem(key);
    } else {
      localStorage.setItem(key, value);
    }
  } else {
    if (value === null) {
      await SecureStore.deleteItemAsync(key);
    } else {
      await SecureStore.setItemAsync(key, value);
    }
  }
}

type State = { isLoading: boolean; session: string | null };
type Action = { type: 'RESTORE_TOKEN'; token: string | null } | { type: 'SIGN_IN'; token: string } | { type: 'SIGN_OUT' };

function authReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'RESTORE_TOKEN':
      return { isLoading: false, session: action.token };
    case 'SIGN_IN':
      return { isLoading: false, session: action.token };
    case 'SIGN_OUT':
      return { isLoading: false, session: null };
    default:
      return state;
  }
}

const API_BASE_URL = 'http://localhost:8080';

export function SessionProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, { isLoading: true, session: null });

  useEffect(() => {
    async function loadToken() {
      let token: string | null = null;
      if (Platform.OS === 'web') {
        token = localStorage.getItem('session');
      } else {
        token = await SecureStore.getItemAsync('session');
      }
      dispatch({ type: 'RESTORE_TOKEN', token });
    }
    loadToken();
  }, []);

  const signIn = useCallback(async (email: string, password: string) => {
    const response = await fetch(`${API_BASE_URL}/api/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
    if (!response.ok) {
      throw new Error('Invalid credentials');
    }
    const data = await response.json();
    const token = data.token;
    await setStorageItemAsync('session', token);
    dispatch({ type: 'SIGN_IN', token });
  }, []);

  const signOut = useCallback(async () => {
    await setStorageItemAsync('session', null);
    dispatch({ type: 'SIGN_OUT' });
  }, []);

  return (
    <AuthContext.Provider value={{ signIn, signOut, session: state.session, isLoading: state.isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}
Build the login form screen

With the auth context ready, you need a screen where users can type their email and password. This screen calls the signIn function from your context and navigates to the main app on success.

  • In the app/ directory, create a new file called sign-in.tsx.
  • Add the following code to build the login form:
import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native';
import { useSession } from '../context/AuthContext';
import { router } from 'expo-router';

export default function SignIn() {
  const { signIn } = useSession();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);

  // Validate inputs, call signIn from context, navigate on success
  const handleLogin = async () => {
    if (!email || !password) {
      Alert.alert('Error', 'Please enter both email and password');
      return;
    }
    setLoading(true);
    try {
      await signIn(email, password);
      router.replace('/');
    } catch (error) {
      Alert.alert('Login Failed', 'Invalid email or password. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Smart Expense Analyzer</Text>
      <Text style={styles.subtitle}>Sign in to continue</Text>
      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        style={styles.input}
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <TouchableOpacity style={styles.button} onPress={handleLogin} disabled={loading}>
        {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Sign In</Text>}
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: '#f5f5f5' },
  title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center', marginBottom: 8, color: '#1a1a2e' },
  subtitle: { fontSize: 16, textAlign: 'center', marginBottom: 32, color: '#666' },
  input: { backgroundColor: '#fff', padding: 16, borderRadius: 8, marginBottom: 16, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
  button: { backgroundColor: '#4c6ef5', padding: 16, borderRadius: 8, alignItems: 'center' },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

What does this code do?

The handleLogin function first checks that both fields are filled in. If they are, it calls signIn() from your auth context, which hits your Spring Boot backend. On success, router.replace('/') navigates to the home screen.

The secureTextEntry prop on the password field hides typed characters with dots. The ActivityIndicator shows a spinning loader while the request is in flight so users know something is happening.

  • Save app/sign-in.tsx.
  • You won't see the login screen yet. The next substep wires the auth context into your app layout, which makes the login screen appear.

✔️ Awesome, I've got everything!

Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native';
import { useSession } from '../context/AuthContext';
import { router } from 'expo-router';

export default function SignIn() {
  const { signIn } = useSession();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);

  const handleLogin = async () => {
    if (!email || !password) {
      Alert.alert('Error', 'Please enter both email and password');
      return;
    }
    setLoading(true);
    try {
      await signIn(email, password);
      router.replace('/');
    } catch (error) {
      Alert.alert('Login Failed', 'Invalid email or password. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Smart Expense Analyzer</Text>
      <Text style={styles.subtitle}>Sign in to continue</Text>
      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        style={styles.input}
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <TouchableOpacity style={styles.button} onPress={handleLogin} disabled={loading}>
        {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Sign In</Text>}
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: '#f5f5f5' },
  title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center', marginBottom: 8, color: '#1a1a2e' },
  subtitle: { fontSize: 16, textAlign: 'center', marginBottom: 32, color: '#666' },
  input: { backgroundColor: '#fff', padding: 16, borderRadius: 8, marginBottom: 16, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
  button: { backgroundColor: '#4c6ef5', padding: 16, borderRadius: 8, alignItems: 'center' },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
Set up route protection in the app layout

The last piece is connecting your SessionProvider to the app's root layout. This makes the auth state available to every screen. You'll also clean up the default scaffold files you don't need.

  • In the VS Code Explorer sidebar, find the app/(tabs)/ folder.
  • Right-click on (tabs) and select Delete to remove the entire folder and its contents.

Why delete the (tabs) folder?

The default Expo scaffold includes a sample tab layout you don't need. You'll build your own tab navigation in a later step with screens tailored to the expense app.

  • Open app/_layout.tsx and replace its entire contents with the following:
import { Slot } from 'expo-router';
import { SessionProvider } from '../context/AuthContext';

// Wrap the entire app in SessionProvider so every screen can access auth state
export default function RootLayout() {
  return (
    <SessionProvider>
      <Slot />
    </SessionProvider>
  );
}

What does this code do?

SessionProvider wraps the entire app, making signIn, signOut, and the session token accessible from any screen.

The Slot component renders whatever child route matches the current URL. Right now the only route is sign-in.tsx, so that's what you'll see.

  • Save app/_layout.tsx.
  • Check your phones and browser. You should see the login screen with the title Smart Expense Analyzer, two input fields (Email and Password), and a blue Sign In button.

Don't see the login screen?

Make sure you deleted the entire app/(tabs)/ folder. If any files remain inside it, Expo Router may try to render them instead of your login screen.

Check that your dev server is still running in the terminal. If it crashed, restart it by running npx expo start again.

Confirm your import path in _layout.tsx matches the folder structure: ../context/AuthContext (not ./context/AuthContext).

Why isn't my login screen showing up after updating _layout.tsx?

✔️ Awesome, I've got everything!

Make sure the file is saved and you can see the login screen on your devices.

ⓧ I'd like to double check the full code

import { Slot } from 'expo-router';
import { SessionProvider } from '../context/AuthContext';

export default function RootLayout() {
  return (
    <SessionProvider>
      <Slot />
    </SessionProvider>
  );
}

Your app now has a complete authentication layer. Users see the login form, their credentials hit your backend, and the JWT token is stored securely on device. Next up, you'll build the camera screen that photographs invoices and sends them to your backend for OCR processing.

Build the Invoice Camera Screen

Your login screen is working and securely storing JWT tokens. Now it's time to build the feature that makes this app useful: photographing invoices and sending them to your backend for processing.

In this step, you'll use expo-camera to access the device camera, capture a photo, and upload it to your Spring Boot OCR endpoint as multipart form data.

In this step, get ready to:
  • Install expo-camera and handle camera permissions on your devices.
  • Capture invoice photos with a live camera preview and scan frame overlay.
  • Upload photos to your backend with the JWT token in the request header.
Install expo-camera and set up permission handling

Before your app can access the camera hardware, it needs the expo-camera package. This package provides both the camera view component and a permissions hook that handles the OS-level permission dialog on iOS and Android.

  • Install expo-camera by running this command in your project terminal:
npx expo install expo-camera

Why npx expo install instead of npm install?

The npx expo install command automatically picks the version of the package that's compatible with your Expo SDK version. Using plain npm install could grab an incompatible version and cause build errors.

  • You should see the package added to your dependencies in the terminal output.

Seeing an install error?

Make sure your terminal is in the project root folder (the one with package.json). Also check your dev server isn't blocking the terminal. You can stop it with Ctrl+C, install the package, then restart with npx expo start.

Why can't I install expo-camera?

Now you'll create the camera screen file. This screen needs to handle three states: waiting for permission, permission denied, and the live camera view.

  • Create a new folder inside your app/ directory called (app).
  • Inside app/(app)/, create a new file called camera.tsx.
  • Paste the following code into camera.tsx to set up the imports, state, and permission handling:
import { useState, useRef } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Image, Alert, ActivityIndicator } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useSession } from '../../context/AuthContext';

const API_BASE_URL = 'http://localhost:8080';

export default function CameraScreen() {
  // Get the JWT token from auth context
  const { session } = useSession();
  // Camera permission state and request function
  const [permission, requestPermission] = useCameraPermissions();
  // Stores the captured photo URI (null when no photo taken)
  const [photo, setPhoto] = useState<string | null>(null);
  // Tracks whether an upload is in progress
  const [uploading, setUploading] = useState(false);
  // Reference to the camera component for capturing photos
  const cameraRef = useRef<CameraView>(null);

  // Show loading text while permission status is being determined
  if (!permission) {
    return <View style={styles.container}><Text>Requesting camera permission...</Text></View>;
  }

  // Show a button to request permission if not yet granted
  if (!permission.granted) {
    return (
      <View style={styles.container}>
        <Text style={styles.permissionText}>Camera access is required to scan invoices.</Text>
        <TouchableOpacity style={styles.button} onPress={requestPermission}>
          <Text style={styles.buttonText}>Grant Permission</Text>
        </TouchableOpacity>
      </View>
    );
  }

What does this code do?

  • useCameraPermissions() is a hook from expo-camera that returns the current permission status and a function to request it.
  • The first if (!permission) handles the brief moment before the OS responds with the permission status.
  • The second if (!permission.granted) shows a friendly prompt when camera access is denied.
  • cameraRef will let you call methods like takePictureAsync on the camera component.

What is the (app) folder?

In Expo Router, folders wrapped in parentheses are route groups. They organize files without adding a URL segment. The (app) group will hold all your authenticated screens (dashboard, camera) while sign-in.tsx stays outside it as a public route.

Implement photo capture with CameraView

With permissions handled, you need the actual camera preview and a way to snap a photo. The CameraView component renders a live feed from the device camera, and takePictureAsync captures a single frame as an image file.

  • In camera.tsx, add the following code directly below the permission-denied if block (after its closing }):
  // Capture a photo from the camera
  const takePicture = async () => {
    if (cameraRef.current) {
      const result = await cameraRef.current.takePictureAsync({ quality: 0.8 });
      if (result) {
        setPhoto(result.uri);
      }
    }
  };

  // Main camera view with overlay frame and capture button
  return (
    <View style={styles.container}>
      <CameraView style={styles.camera} ref={cameraRef} facing="back">
        <View style={styles.overlay}>
          <View style={styles.scanFrame} />
          <Text style={styles.instructionText}>Position the invoice within the frame</Text>
        </View>
      </CameraView>
      <TouchableOpacity style={styles.captureButton} onPress={takePicture}>
        <View style={styles.captureInner} />
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#000' },
  camera: { flex: 1 },
  overlay: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  scanFrame: { width: 280, height: 380, borderWidth: 2, borderColor: 'rgba(255,255,255,0.7)', borderRadius: 12 },
  instructionText: { color: '#fff', marginTop: 16, fontSize: 14 },
  captureButton: { position: 'absolute', bottom: 40, alignSelf: 'center', width: 72, height: 72, borderRadius: 36, backgroundColor: 'rgba(255,255,255,0.3)', justifyContent: 'center', alignItems: 'center' },
  captureInner: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#fff' },
  button: { backgroundColor: '#4c6ef5', paddingVertical: 14, paddingHorizontal: 32, borderRadius: 8 },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  permissionText: { fontSize: 16, textAlign: 'center', color: '#333', marginBottom: 16 },
});

What does this code do?

  • takePictureAsync({ quality: 0.8 }) captures a photo at 80% quality (balancing file size with clarity for OCR).
  • CameraView renders the live camera feed. The facing="back" prop uses the rear camera (better for document scanning).
  • The scanFrame is a bordered rectangle overlay that helps users position the invoice correctly.
  • The circular capture button (white circle inside a translucent ring) follows the standard camera app pattern users expect.
  • Save camera.tsx.
  • On your phone in Expo Go, navigate to the camera screen. Your phone will prompt you to allow camera access.
  • Tap Allow to grant camera permission.
  • You should see a live camera feed with a white rectangular frame overlay and a circular capture button at the bottom.

Testing on web vs phone

The camera works on web browsers too (using the webcam), but the best testing experience is on your physical phone. If you're running on web and don't see a camera prompt, check that your browser allows camera access for localhost.

Camera not showing up?

Make sure you tapped Allow on the permission dialog. If you accidentally denied it, go to your phone's Settings, find the Expo Go app, and re-enable camera access.

If you see a "styles is not defined" error, make sure the StyleSheet.create block is outside the component function (after the closing }).

Why isn't my camera view rendering in Expo Go?

Write the upload function and photo preview

Right now, tapping the capture button saves the photo URI to state, but nothing visible happens. You need a preview screen that shows the captured photo and lets the user either retake it or upload it to the backend.

  • In camera.tsx, find the takePicture function. Add the following code directly below it (before the return statement with the CameraView):
  // Upload the captured photo to the backend as multipart form data
  const uploadInvoice = async () => {
    if (!photo) return;
    setUploading(true);
    try {
      const formData = new FormData();
      formData.append('file', {
        uri: photo,
        type: 'image/jpeg',
        name: 'invoice.jpg',
      } as any);

      const response = await fetch(`${API_BASE_URL}/api/invoices/upload`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${session}` },
        body: formData,
      });

      if (response.ok) {
        Alert.alert('Success', 'Invoice uploaded and sent for processing!');
        setPhoto(null);
      } else {
        Alert.alert('Error', 'Failed to upload invoice. Please try again.');
      }
    } catch (error) {
      Alert.alert('Error', 'Network error. Make sure your backend is running.');
    } finally {
      setUploading(false);
    }
  };

  // Show the photo preview with retake and upload options
  if (photo) {
    return (
      <View style={styles.container}>
        <Image source={{ uri: photo }} style={styles.preview} />
        <View style={styles.previewButtons}>
          <TouchableOpacity style={[styles.button, styles.retakeButton]} onPress={() => setPhoto(null)}>
            <Text style={styles.buttonText}>Retake</Text>
          </TouchableOpacity>
          <TouchableOpacity style={styles.button} onPress={uploadInvoice} disabled={uploading}>
            {uploading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Upload</Text>}
          </TouchableOpacity>
        </View>
      </View>
    );
  }

What does this code do?

  • uploadInvoice builds a FormData object with the photo file. This is how browsers and mobile apps send files to servers (multipart form data).
  • The Authorization: Bearer ${session} header attaches your JWT token so the backend knows which user is uploading.
  • The if (photo) block is an early return. When a photo exists, the component renders the preview instead of the camera view.
  • Tapping Retake sets photo back to null, which hides the preview and shows the camera again.

You also need to add three styles for the preview UI to the existing StyleSheet.create object.

  • In your styles object at the bottom of the file, add these three properties after captureInner:
  preview: { flex: 1, resizeMode: 'contain' },
  previewButtons: { flexDirection: 'row', justifyContent: 'space-around', padding: 24, backgroundColor: '#1a1a2e' },
  retakeButton: { backgroundColor: '#666' },

What do these styles do?

The preview style fills the screen with the captured image. The previewButtons row places the Retake and Upload buttons side by side at the bottom. The grey retakeButton color visually de-emphasizes it compared to the primary Upload button.

  • Save camera.tsx.
  • On your phone, tap the white capture button to take a photo.
  • You should see a full-screen preview of your photo with a grey Retake button and a blue Upload button at the bottom.
  • Tap Retake to confirm it returns you to the live camera view.

Network access for physical devices

The API_BASE_URL is set to http://localhost:8080 which works when testing on web. For your phone to reach the backend, replace localhost with your computer's local IP address (e.g. http://192.168.1.x:8080). Find your IP in your WiFi network settings.

Photo preview not appearing after capture?

Make sure the if (photo) block is placed ABOVE the main return statement that renders the CameraView. If it comes after, the camera return executes first and the preview never shows.

Also confirm the preview style is added inside the StyleSheet.create({}) object, not outside it.

My photo preview isn't rendering after I tap the capture button.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here's the complete app/(app)/camera.tsx file for comparison:

import { useState, useRef } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Image, Alert, ActivityIndicator } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useSession } from '../../context/AuthContext';

const API_BASE_URL = 'http://localhost:8080';

export default function CameraScreen() {
  const { session } = useSession();
  const [permission, requestPermission] = useCameraPermissions();
  const [photo, setPhoto] = useState<string | null>(null);
  const [uploading, setUploading] = useState(false);
  const cameraRef = useRef<CameraView>(null);

  if (!permission) {
    return <View style={styles.container}><Text>Requesting camera permission...</Text></View>;
  }

  if (!permission.granted) {
    return (
      <View style={styles.container}>
        <Text style={styles.permissionText}>Camera access is required to scan invoices.</Text>
        <TouchableOpacity style={styles.button} onPress={requestPermission}>
          <Text style={styles.buttonText}>Grant Permission</Text>
        </TouchableOpacity>
      </View>
    );
  }

  const takePicture = async () => {
    if (cameraRef.current) {
      const result = await cameraRef.current.takePictureAsync({ quality: 0.8 });
      if (result) {
        setPhoto(result.uri);
      }
    }
  };

  const uploadInvoice = async () => {
    if (!photo) return;
    setUploading(true);
    try {
      const formData = new FormData();
      formData.append('file', {
        uri: photo,
        type: 'image/jpeg',
        name: 'invoice.jpg',
      } as any);

      const response = await fetch(`${API_BASE_URL}/api/invoices/upload`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${session}` },
        body: formData,
      });

      if (response.ok) {
        Alert.alert('Success', 'Invoice uploaded and sent for processing!');
        setPhoto(null);
      } else {
        Alert.alert('Error', 'Failed to upload invoice. Please try again.');
      }
    } catch (error) {
      Alert.alert('Error', 'Network error. Make sure your backend is running.');
    } finally {
      setUploading(false);
    }
  };

  if (photo) {
    return (
      <View style={styles.container}>
        <Image source={{ uri: photo }} style={styles.preview} />
        <View style={styles.previewButtons}>
          <TouchableOpacity style={[styles.button, styles.retakeButton]} onPress={() => setPhoto(null)}>
            <Text style={styles.buttonText}>Retake</Text>
          </TouchableOpacity>
          <TouchableOpacity style={styles.button} onPress={uploadInvoice} disabled={uploading}>
            {uploading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Upload</Text>}
          </TouchableOpacity>
        </View>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <CameraView style={styles.camera} ref={cameraRef} facing="back">
        <View style={styles.overlay}>
          <View style={styles.scanFrame} />
          <Text style={styles.instructionText}>Position the invoice within the frame</Text>
        </View>
      </CameraView>
      <TouchableOpacity style={styles.captureButton} onPress={takePicture}>
        <View style={styles.captureInner} />
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#000' },
  camera: { flex: 1 },
  overlay: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  scanFrame: { width: 280, height: 380, borderWidth: 2, borderColor: 'rgba(255,255,255,0.7)', borderRadius: 12 },
  instructionText: { color: '#fff', marginTop: 16, fontSize: 14 },
  captureButton: { position: 'absolute', bottom: 40, alignSelf: 'center', width: 72, height: 72, borderRadius: 36, backgroundColor: 'rgba(255,255,255,0.3)', justifyContent: 'center', alignItems: 'center' },
  captureInner: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#fff' },
  preview: { flex: 1, resizeMode: 'contain' },
  previewButtons: { flexDirection: 'row', justifyContent: 'space-around', padding: 24, backgroundColor: '#1a1a2e' },
  button: { backgroundColor: '#4c6ef5', paddingVertical: 14, paddingHorizontal: 32, borderRadius: 8 },
  retakeButton: { backgroundColor: '#666' },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  permissionText: { fontSize: 16, textAlign: 'center', color: '#333', marginBottom: 16 },
});

Your camera-to-backend pipeline is ready. You can photograph invoices and send them to your Spring Boot server for OCR processing. Next up, you'll build the spending dashboard with charts that visualize the data your backend returns.

Build the Spending Dashboard with Charts

Your camera screen can now photograph invoices and upload them to your backend for OCR processing.

Raw data is only useful if you can see it at a glance. In this step, you'll build a spending dashboard that pulls analytics data from your Spring Boot API and renders it as interactive charts.

In this step, get ready to:
  • Install the react-native-gifted-charts library and its peer dependencies.
  • Build a donut pie chart that visualizes spending by category.
  • Add a bar chart showing monthly spending totals with pull-to-refresh.
Install react-native-gifted-charts and fetch analytics data

The react-native-gifted-charts library provides pie charts, bar charts, and line charts from a single package. It needs two peer dependencies: react-native-svg for rendering vector graphics and expo-linear-gradient for gradient fills.

  • Install the charting library and its dependencies by running this command in your terminal:
npx expo install react-native-gifted-charts expo-linear-gradient react-native-svg

Why npx expo install instead of npm install?

The npx expo install command automatically picks package versions compatible with your Expo SDK. This avoids version mismatches that break builds.

  • You should see packages added to your package.json with a success message in the terminal.

Seeing dependency conflicts?

Make sure you're using npx expo install and not npm install. The Expo version ensures SDK compatibility.

help me fix dependency conflicts when installing react-native-gifted-charts

Now you'll create the dashboard screen. This file lives at app/(app)/index.tsx, which makes it the default screen inside your protected route group.

  • Create a new file called index.tsx inside your app/(app)/ folder.
  • Add the following imports, types, and data-fetching logic:
import { useState, useEffect, useCallback } from 'react';
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native';
import { PieChart, BarChart } from 'react-native-gifted-charts';
import { useSession } from '../../context/AuthContext';

const API_BASE_URL = 'http://localhost:8080';

// Define the shape of data for each chart type
type CategoryData = { value: number; color: string; text: string; label: string };
type MonthlyData = { value: number; label: string; frontColor: string };

export default function Dashboard() {
  const { session } = useSession();
  const [categoryData, setCategoryData] = useState<CategoryData[]>([]);
  const [monthlyData, setMonthlyData] = useState<MonthlyData[]>([]);
  const [totalSpending, setTotalSpending] = useState(0);
  const [refreshing, setRefreshing] = useState(false);

  // Fetch analytics data from the backend
  const fetchAnalytics = useCallback(async () => {
    try {
      const headers = { Authorization: `Bearer ${session}`, 'Content-Type': 'application/json' };

      // Fetch spending by category for the pie chart
      const categoryResponse = await fetch(`${API_BASE_URL}/api/analytics/spending-by-category`, { headers });
      const categoryJson = await categoryResponse.json();

      // Assign a color to each category
      const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'];
      const formattedCategories: CategoryData[] = categoryJson.map((item: any, index: number) => ({
        value: item.amount,
        color: colors[index % colors.length],
        text: `${Math.round(item.percentage)}%`,
        label: item.category,
      }));
      setCategoryData(formattedCategories);

      // Fetch monthly totals for the bar chart
      const monthlyResponse = await fetch(`${API_BASE_URL}/api/analytics/monthly-spending`, { headers });
      const monthlyJson = await monthlyResponse.json();

      const formattedMonthly: MonthlyData[] = monthlyJson.map((item: any) => ({
        value: item.total,
        label: item.month,
        frontColor: '#4c6ef5',
      }));
      setMonthlyData(formattedMonthly);

      // Calculate total spending across all categories
      const total = formattedCategories.reduce((sum: number, item: CategoryData) => sum + item.value, 0);
      setTotalSpending(total);
    } catch (error) {
      console.error('Failed to fetch analytics:', error);
    }
  }, [session]);

  // Fetch data when the component mounts
  useEffect(() => {
    fetchAnalytics();
  }, [fetchAnalytics]);

  // Pull-to-refresh handler
  const onRefresh = useCallback(async () => {
    setRefreshing(true);
    await fetchAnalytics();
    setRefreshing(false);
  }, [fetchAnalytics]);

  return (
    <ScrollView
      style={styles.container}
      refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
    >
      <Text style={styles.header}>Spending Overview</Text>
      <Text style={styles.totalAmount}>${totalSpending.toLocaleString()}</Text>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#f5f5f5', padding: 16 },
  header: { fontSize: 24, fontWeight: 'bold', color: '#1a1a2e', marginBottom: 4 },
  totalAmount: { fontSize: 36, fontWeight: 'bold', color: '#4c6ef5', marginBottom: 24 },
  chartCard: { backgroundColor: '#fff', borderRadius: 12, padding: 16, marginBottom: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4, elevation: 2 },
  chartTitle: { fontSize: 18, fontWeight: '600', marginBottom: 16, color: '#333' },
  chartContainer: { alignItems: 'center', marginBottom: 16 },
  centerLabel: { fontSize: 14, color: '#666' },
  legend: { marginTop: 8 },
  legendItem: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
  legendColor: { width: 12, height: 12, borderRadius: 6, marginRight: 8 },
  legendText: { fontSize: 14, color: '#555' },
  axisLabel: { fontSize: 10, color: '#888' },
});

What does this code do?

The fetchAnalytics function calls two backend endpoints: one for category breakdowns and one for monthly totals. It maps the raw JSON responses into the format that react-native-gifted-charts expects.

The useCallback hook memoizes the function so it only gets recreated when the session token changes. The useEffect then calls it once on mount.

  • Save app/(app)/index.tsx.
  • Start your dev server with npx expo start --web. You should see "Spending Overview" and "$0" displayed on screen.

Seeing a blank screen or import error?

Make sure you ran the install command with all three packages: react-native-gifted-charts, expo-linear-gradient, and react-native-svg. Missing peer dependencies cause runtime crashes.

help me debug my dashboard screen not rendering

Build the pie chart with a category legend

The pie chart gives users an instant visual breakdown of where their money goes. Each colored segment represents a spending category like Food, Transport, or Rent.

  • In app/(app)/index.tsx, find this line inside the return statement:
      <Text style={styles.totalAmount}>${totalSpending.toLocaleString()}</Text>
  • Add the following pie chart card directly below that line:
      {/* Pie chart card showing spending by category */}
      <View style={styles.chartCard}>
        <Text style={styles.chartTitle}>Spending by Category</Text>
        {categoryData.length > 0 && (
          <View style={styles.chartContainer}>
            <PieChart
              data={categoryData}
              donut
              radius={100}
              innerRadius={60}
              centerLabelComponent={() => (
                <Text style={styles.centerLabel}>Total</Text>
              )}
            />
          </View>
        )}
        {/* Color-coded legend for each category */}
        <View style={styles.legend}>
          {categoryData.map((item, index) => (
            <View key={index} style={styles.legendItem}>
              <View style={[styles.legendColor, { backgroundColor: item.color }]} />
              <Text style={styles.legendText}>{item.label}: ${item.value}</Text>
            </View>
          ))}
        </View>
      </View>

What does this code do?

  • The donut prop turns the pie chart into a ring shape, creating space for a center label.
  • The radius and innerRadius props control the outer and inner ring sizes.
  • The conditional categoryData.length > 0 prevents rendering the chart with empty data, which would crash.
  • The legend maps over the same data array to show a colored square and label for each category.
  • Save app/(app)/index.tsx and refresh your browser.
  • If your backend is running with analytics data, you should see a donut chart with colored segments and a legend below it. If the backend is not running, the chart section stays empty (no crash).

Chart not rendering even with backend running?

Open your browser's DevTools console. If you see a CORS error, your Spring Boot backend needs to allow requests from the Expo dev server origin. Check that your backend's CORS configuration includes http://localhost:8081 (or whatever port Expo uses).

help me debug why my pie chart data isn't loading

Add the bar chart for monthly spending totals

The bar chart shows spending trends over time. Each bar represents one month's total expenses, making it easy to spot months where spending spiked.

  • In app/(app)/index.tsx, find the closing </View> tag of the pie chart card (the one just before </ScrollView>).
  • Add the bar chart card below it:
      {/* Bar chart card showing monthly totals */}
      <View style={styles.chartCard}>
        <Text style={styles.chartTitle}>Monthly Spending</Text>
        {monthlyData.length > 0 && (
          <BarChart
            data={monthlyData}
            barWidth={32}
            spacing={20}
            xAxisLabelTextStyle={styles.axisLabel}
            yAxisTextStyle={styles.axisLabel}
            noOfSections={4}
            barBorderRadius={4}
          />
        )}
      </View>

What does this code do?

  • The barWidth and spacing props control how thick each bar is and how far apart they sit.
  • The noOfSections prop divides the Y-axis into 4 gridlines for readability.
  • The barBorderRadius rounds the top corners of each bar for a polished look.
  • Each bar's label (the month name) appears on the X-axis automatically.

What is pull-to-refresh?

The RefreshControl component (already wired up in your ScrollView) lets users swipe down to reload fresh data from the backend. The onRefresh function sets refreshing to true (showing a spinner), re-fetches analytics, then sets it back to false.

  • Save app/(app)/index.tsx and refresh your browser.
  • You should see the bar chart below the pie chart. If your backend has monthly data, each bar represents a month's total spending.
  • On a physical device, pull down on the screen to trigger a refresh spinner.

Bar chart overflowing or bars too wide?

If you have many months of data, the chart may overflow horizontally. This is expected. You can scroll the chart card sideways on mobile. Make sure the barWidth is set to 32 and spacing is set to 20 as shown in the code above.

help me fix my bar chart layout

Testing without a running backend

If your Spring Boot backend isn't running, the charts simply won't render (the empty-data checks prevent crashes). You can verify the layout is correct and connect to live data later.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/(app)/index.tsx file:

import { useState, useEffect, useCallback } from 'react';
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native';
import { PieChart, BarChart } from 'react-native-gifted-charts';
import { useSession } from '../../context/AuthContext';

const API_BASE_URL = 'http://localhost:8080';

type CategoryData = { value: number; color: string; text: string; label: string };
type MonthlyData = { value: number; label: string; frontColor: string };

export default function Dashboard() {
  const { session } = useSession();
  const [categoryData, setCategoryData] = useState<CategoryData[]>([]);
  const [monthlyData, setMonthlyData] = useState<MonthlyData[]>([]);
  const [totalSpending, setTotalSpending] = useState(0);
  const [refreshing, setRefreshing] = useState(false);

  const fetchAnalytics = useCallback(async () => {
    try {
      const headers = { Authorization: `Bearer ${session}`, 'Content-Type': 'application/json' };

      const categoryResponse = await fetch(`${API_BASE_URL}/api/analytics/spending-by-category`, { headers });
      const categoryJson = await categoryResponse.json();

      const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'];
      const formattedCategories: CategoryData[] = categoryJson.map((item: any, index: number) => ({
        value: item.amount,
        color: colors[index % colors.length],
        text: `${Math.round(item.percentage)}%`,
        label: item.category,
      }));
      setCategoryData(formattedCategories);

      const monthlyResponse = await fetch(`${API_BASE_URL}/api/analytics/monthly-spending`, { headers });
      const monthlyJson = await monthlyResponse.json();

      const formattedMonthly: MonthlyData[] = monthlyJson.map((item: any) => ({
        value: item.total,
        label: item.month,
        frontColor: '#4c6ef5',
      }));
      setMonthlyData(formattedMonthly);

      const total = formattedCategories.reduce((sum: number, item: CategoryData) => sum + item.value, 0);
      setTotalSpending(total);
    } catch (error) {
      console.error('Failed to fetch analytics:', error);
    }
  }, [session]);

  useEffect(() => {
    fetchAnalytics();
  }, [fetchAnalytics]);

  const onRefresh = useCallback(async () => {
    setRefreshing(true);
    await fetchAnalytics();
    setRefreshing(false);
  }, [fetchAnalytics]);

  return (
    <ScrollView
      style={styles.container}
      refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
    >
      <Text style={styles.header}>Spending Overview</Text>
      <Text style={styles.totalAmount}>${totalSpending.toLocaleString()}</Text>

      <View style={styles.chartCard}>
        <Text style={styles.chartTitle}>Spending by Category</Text>
        {categoryData.length > 0 && (
          <View style={styles.chartContainer}>
            <PieChart
              data={categoryData}
              donut
              radius={100}
              innerRadius={60}
              centerLabelComponent={() => (
                <Text style={styles.centerLabel}>Total</Text>
              )}
            />
          </View>
        )}
        <View style={styles.legend}>
          {categoryData.map((item, index) => (
            <View key={index} style={styles.legendItem}>
              <View style={[styles.legendColor, { backgroundColor: item.color }]} />
              <Text style={styles.legendText}>{item.label}: ${item.value}</Text>
            </View>
          ))}
        </View>
      </View>

      <View style={styles.chartCard}>
        <Text style={styles.chartTitle}>Monthly Spending</Text>
        {monthlyData.length > 0 && (
          <BarChart
            data={monthlyData}
            barWidth={32}
            spacing={20}
            xAxisLabelTextStyle={styles.axisLabel}
            yAxisTextStyle={styles.axisLabel}
            noOfSections={4}
            barBorderRadius={4}
          />
        )}
      </View>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#f5f5f5', padding: 16 },
  header: { fontSize: 24, fontWeight: 'bold', color: '#1a1a2e', marginBottom: 4 },
  totalAmount: { fontSize: 36, fontWeight: 'bold', color: '#4c6ef5', marginBottom: 24 },
  chartCard: { backgroundColor: '#fff', borderRadius: 12, padding: 16, marginBottom: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4, elevation: 2 },
  chartTitle: { fontSize: 18, fontWeight: '600', marginBottom: 16, color: '#333' },
  chartContainer: { alignItems: 'center', marginBottom: 16 },
  centerLabel: { fontSize: 14, color: '#666' },
  legend: { marginTop: 8 },
  legendItem: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
  legendColor: { width: 12, height: 12, borderRadius: 6, marginRight: 8 },
  legendText: { fontSize: 14, color: '#555' },
  axisLabel: { fontSize: 10, color: '#888' },
});

Testing on your phone vs. web

The API_BASE_URL is set to http://localhost:8080, which works when testing in a web browser on the same machine as your backend.

When testing on a physical phone, replace localhost with your computer's local IP address (e.g. http://192.168.1.x:8080) so the phone can reach the backend over WiFi.

Your dashboard is pulling analytics data and rendering it as interactive charts. Next up, you'll wire all your screens together with tab navigation so users can switch between the dashboard and camera with a single tap.

Connect Screens with Tab Navigation

Your dashboard is pulling real spending data and rendering it as interactive charts. Now you need to connect all your screens together so users can navigate between them.

Right now your screens exist as standalone files. Expo Router uses a layout file inside the app/(app)/ folder to define how screens relate to each other. In this step, you will create that layout with tab navigation, route protection, and a logout button.

In this step, get ready to:
  • Create a tab layout that links the Dashboard and Camera screens together.
  • Add route protection and a logout header.
  • Test the complete app flow on your phone and web browser.
Set up tab navigation in the app layout

In Expo Router, the _layout.tsx file inside a route group controls how all sibling screens are presented. By exporting a Tabs component, you tell Expo Router to render a tab bar at the bottom that switches between your screens.

  • Create a new file called _layout.tsx inside your app/(app)/ folder.
  • Paste the following code into the file:
import { Redirect } from 'expo-router';
import { Tabs } from 'expo-router';
import { Text, TouchableOpacity } from 'react-native';
import { useSession } from '../../context/AuthContext';
import Ionicons from '@expo/vector-icons/Ionicons';

export default function AppLayout() {
  const { session, isLoading, signOut } = useSession();

  // Show loading text while checking for a stored token
  if (isLoading) {
    return <Text>Loading...</Text>;
  }

  // Redirect unauthenticated users to the login screen
  if (!session) {
    return <Redirect href="/sign-in" />;
  }

  return (
    <Tabs
      screenOptions={{
        // Logout button in the top-right header of every tab
        headerRight: () => (
          <TouchableOpacity onPress={signOut} style={{ marginRight: 16 }}>
            <Ionicons name="log-out-outline" size={24} color="#4c6ef5" />
          </TouchableOpacity>
        ),
        tabBarActiveTintColor: '#4c6ef5',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Dashboard',
          tabBarIcon: ({ color, size }) => <Ionicons name="stats-chart" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="camera"
        options={{
          title: 'Scan Invoice',
          tabBarIcon: ({ color, size }) => <Ionicons name="camera" size={size} color={color} />,
        }}
      />
    </Tabs>
  );
}

What does this code do?

This layout file does three things at once: route protection, tab navigation, and a logout header.

  • The if (!session) check redirects unauthenticated users back to the sign-in screen. This protects every screen inside app/(app)/.
  • The Tabs component renders a bottom tab bar. Each Tabs.Screen maps a file name (like index) to a tab with an icon and label.
  • The headerRight option inside screenOptions adds a logout icon to the top-right corner of every screen. Tapping it calls signOut from your auth context.
  • Ionicons comes from @expo/vector-icons, which is already included in your Expo project. No extra install needed.
  • Save _layout.tsx.
  • Check your Expo dev server terminal. You should see the app reload without errors.
  • On your phone (or web browser), you should now see a tab bar at the bottom with Dashboard and Scan Invoice tabs, plus a logout icon in the top-right header.

Not seeing the tab bar?

  • Make sure the file is named exactly _layout.tsx (with the underscore prefix) inside app/(app)/. Expo Router only recognizes layout files with this exact naming convention.
  • If you see a blank screen, check that your AuthContext is providing a valid session token. The redirect logic sends users to /sign-in if no token exists.
  • If you previously had a different layout file (from the default template) in this folder, delete it and replace with the code above.

Why isn't my tab navigation showing up in Expo Router?

Create a reusable API service

Your screens currently make fetch calls directly. As the app grows, centralizing API logic into a service file keeps your code DRY and makes it easier to handle common patterns like auth headers and error responses.

  • Create a new folder called services in your project root (at the same level as app/ and context/).
  • Create a file called api.ts inside the services/ folder.
  • Paste the following code into services/api.ts:
const API_BASE_URL = 'http://localhost:8080';

// Generic authenticated fetch wrapper
export async function fetchWithAuth(endpoint: string, token: string, options: RequestInit = {}) {
  const response = await fetch(`${API_BASE_URL}${endpoint}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
      ...options.headers,
    },
  });

  if (response.status === 401) {
    throw new Error('Unauthorized');
  }

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

// Login helper that returns the JWT token
export async function loginUser(email: string, password: string): Promise<{ token: string }> {
  const response = await fetch(`${API_BASE_URL}/api/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (!response.ok) {
    throw new Error('Invalid credentials');
  }

  return response.json();
}

// Upload an invoice image as multipart form data
export async function uploadInvoiceImage(file: { uri: string; type: string; name: string }, token: string) {
  const formData = new FormData();
  formData.append('file', file as any);

  const response = await fetch(`${API_BASE_URL}/api/invoices/upload`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
    body: formData,
  });

  if (!response.ok) {
    throw new Error('Upload failed');
  }

  return response.json();
}

What does this code do?

  • fetchWithAuth() is a generic wrapper that attaches the JWT token to any API request and throws meaningful errors for 401 (unauthorized) or other failures.
  • loginUser() encapsulates the login POST request. It returns the token object on success.
  • uploadInvoiceImage() handles the multipart form data upload pattern. Notice it does NOT set Content-Type manually because the browser sets the correct boundary for multipart requests.
  • Save services/api.ts.

You won't see a visible change yet. This utility file is ready for future refactoring when you want to replace inline fetch calls with cleaner service functions.

Test the complete app flow

Your app now has all its screens wired together. Time to run through the full experience on your devices.

Connecting physical devices to your local backend

Your backend runs at http://localhost:8080, which works fine when testing on web. Physical phones cannot reach localhost because that refers to the phone itself.

To test on your iPhone or Android phone, replace localhost with your computer's local IP address (e.g., http://192.168.1.42:8080). Find your IP in your WiFi network settings. Both your phone and computer must be on the same WiFi network.

  • Make sure your Spring Boot backend is running on port 8080.
  • Start your Expo dev server by running:
npx expo start
  • Scan the QR code with your iPhone camera app or the Expo Go app on Android.
  • You should land on the sign-in screen. Enter your credentials and tap Sign In.
  • After logging in, you should see the Dashboard tab with your pie chart and bar chart loaded.
  • Tap the Scan Invoice tab. Grant camera permission when prompted, then take a photo of any receipt or document.
  • Tap Upload to send the image to your backend. You should see a success alert.
  • Switch back to the Dashboard tab and pull down to refresh. Your spending data should update.
  • Tap the logout icon in the top-right header. You should be redirected back to the sign-in screen.

Test on web too

Press w in your Expo dev server terminal to open the app in your web browser. The same tab navigation and login flow works on web. The camera tab uses your webcam if available.

Login failing on your phone?

  • If you see a network error, your phone cannot reach the backend. Replace localhost with your computer's local IP in AuthContext.tsx, app/(app)/index.tsx, app/(app)/camera.tsx, and services/api.ts.
  • Make sure your phone and computer are on the same WiFi network.
  • Check that your Spring Boot backend is actually running. Look for the "Started Application" log line in your backend terminal.

Help me debug why my Expo app cannot connect to my Spring Boot backend.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/(app)/_layout.tsx file:

import { Redirect } from 'expo-router';
import { Tabs } from 'expo-router';
import { Text, TouchableOpacity } from 'react-native';
import { useSession } from '../../context/AuthContext';
import Ionicons from '@expo/vector-icons/Ionicons';

export default function AppLayout() {
  const { session, isLoading, signOut } = useSession();

  if (isLoading) {
    return <Text>Loading...</Text>;
  }

  if (!session) {
    return <Redirect href="/sign-in" />;
  }

  return (
    <Tabs
      screenOptions={{
        headerRight: () => (
          <TouchableOpacity onPress={signOut} style={{ marginRight: 16 }}>
            <Ionicons name="log-out-outline" size={24} color="#4c6ef5" />
          </TouchableOpacity>
        ),
        tabBarActiveTintColor: '#4c6ef5',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Dashboard',
          tabBarIcon: ({ color, size }) => <Ionicons name="stats-chart" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="camera"
        options={{
          title: 'Scan Invoice',
          tabBarIcon: ({ color, size }) => <Ionicons name="camera" size={size} color={color} />,
        }}
      />
    </Tabs>
  );
}

Here is the complete services/api.ts file:

const API_BASE_URL = 'http://localhost:8080';

export async function fetchWithAuth(endpoint: string, token: string, options: RequestInit = {}) {
  const response = await fetch(`${API_BASE_URL}${endpoint}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
      ...options.headers,
    },
  });

  if (response.status === 401) {
    throw new Error('Unauthorized');
  }

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

export async function loginUser(email: string, password: string): Promise<{ token: string }> {
  const response = await fetch(`${API_BASE_URL}/api/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (!response.ok) {
    throw new Error('Invalid credentials');
  }

  return response.json();
}

export async function uploadInvoiceImage(file: { uri: string; type: string; name: string }, token: string) {
  const formData = new FormData();
  formData.append('file', file as any);

  const response = await fetch(`${API_BASE_URL}/api/invoices/upload`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
    body: formData,
  });

  if (!response.ok) {
    throw new Error('Upload failed');
  }

  return response.json();
}

Your cross-platform expense app is fully functional. You have authentication, camera capture, chart-based analytics, and tab navigation all running from a single codebase on iOS, Android, and web. Next up, the Secret Mission challenges you to add interactive spending trends with filtering.

Secret mission

Add an Interactive Spending Trends Chart with Category Filtering

Your dashboard shows totals, but what about trends? In this secret mission, you'll add a third tab with an interactive line chart that shows spending over time. Users can filter by category and tap data points to see exact amounts.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Expo project folder on your Desktop (your entire app codebase).
  • Node.js and npm (installed via Homebrew).
  • Expo Go app on your iPhone and Android phone.
  • npm packages installed in your project (expo-camera, expo-secure-store, react-native-gifted-charts).
  • Expo dev server (running process in your terminal).

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue working on the app soon.

  • Your Expo dev server stays running in the terminal.
  • Your project folder and all installed packages remain on disk.
  • Expo Go stays installed on your phones, ready to reconnect when you start the dev server again.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files stay intact so you can pick up where you left off.

  • Press Ctrl+C in the terminal where the Expo dev server is running to stop it.
  • Your project folder stays on your Desktop. No files are lost.
  • To resume later, open your terminal, navigate to the project folder, and run npx expo start again.
  • Expo Go can stay on your phones. It uses minimal storage.

Delete - I don't want to use this again

Remove all project resources and start fresh. This frees up disk space and removes everything you built in this project.

  • Press Ctrl+C in the terminal to stop the Expo dev server.
  • Delete your project folder from the Desktop.
  • Uninstall Expo Go from your iPhone and Android phone if you no longer need it.
  • Optionally uninstall Node.js if no other projects depend on it. Run brew uninstall node in your terminal.

Nice Work!

Nice Work!

You just built a cross-platform expense tracking app that runs on your iPhone, Android phone, and web browser from a single TypeScript codebase.

What you learned:

  • Built a complete Expo and React Native app from scratch that targets iOS, Android, and web from one codebase.
  • Implemented JWT authentication with secure token storage and a camera-to-backend pipeline that captures invoice photos and uploads them to your Spring Boot API.
  • Created interactive charts (pie and bar) with react-native-gifted-charts to visualize real spending data pulled from your backend analytics endpoints.
  • Secret Mission: Added an interactive spending trends line chart with category filtering and date range selection.

Ready to quiz yourself?