Project 8: Photo Journal with Camera and File System

React Native with Expo: 10 Real Projects · Advanced Apps and Release · 8 min read · Updated

Using the camera safely

  1. The user taps Take photo.
  2. The app asks for camera permission and stops with a message if it is denied.
  3. If it is allowed, the picker opens the camera and returns a temporary file address.
  4. The app copies the file into its own folder and saves the caption and date.

Phones have a camera, a photo library and file storage, and apps use them constantly. This project teaches how to use device features safely. The most important lesson is permissions. The user must agree before your app uses the camera or photos, and your app must still work when they say no.

Concepts in this project

  • expo-image-picker: opens the photo library or the camera. It returns a temporary file address (uri).
  • Permissions: call requestCameraPermissionsAsync or requestMediaLibraryPermissionsAsync and check granted. Explain, with Alert, what to do when permission is denied.
  • expo-file-system: copy the picked photo into the app's own document folder so it stays after the temporary file is cleaned up.
  • Metadata in AsyncStorage: the caption, date and file address for each entry.
  • Cleaning up: delete the file when the entry is deleted, so storage does not fill with orphan photos.
  • Permission texts: iOS shows a message you write in app.json. Use clear, honest text.

Create the project

  1. Use a blank Expo TypeScript project.
  2. Install the libraries: npx expo install expo-image-picker expo-file-system @react-native-async-storage/async-storage.
  3. Add the permission texts to app.json (below).
  4. Add journal.ts and replace App.tsx.

app.json permission texts

// app.json: texts shown to the user when the app asks for permission
{
  "expo": {
    "plugins": [
      [
        "expo-image-picker",
        {
          "photosPermission": "Choose photos for your journal.",
          "cameraPermission": "Take photos for your journal."
        }
      ],
      [
        "expo-notifications",
        { "color": "#3b5bdb" }
      ]
    ]
  }
}

`journal.ts`

import * as FileSystem from "expo-file-system";
import AsyncStorage from "@react-native-async-storage/async-storage";

export type Entry = { id: string; caption: string; uri: string; createdAt: number };
const KEY = "journal.v1";
const DIR = (FileSystem.documentDirectory ?? "") + "photos/";

export async function loadEntries(): Promise<Entry[]> {
  const raw = await AsyncStorage.getItem(KEY);
  return raw ? (JSON.parse(raw) as Entry[]) : [];
}

export async function addEntry(sourceUri: string, caption: string): Promise<Entry[]> {
  await FileSystem.makeDirectoryAsync(DIR, { intermediates: true });
  const id = Date.now().toString();
  const target = `${DIR}${id}.jpg`;
  await FileSystem.copyAsync({ from: sourceUri, to: target });
  const entries = [{ id, caption, uri: target, createdAt: Date.now() }, ...(await loadEntries())];
  await AsyncStorage.setItem(KEY, JSON.stringify(entries));
  return entries;
}

export async function removeEntry(id: string): Promise<Entry[]> {
  const entries = await loadEntries();
  const gone = entries.find((e) => e.id === id);
  if (gone) await FileSystem.deleteAsync(gone.uri, { idempotent: true });
  const left = entries.filter((e) => e.id !== id);
  await AsyncStorage.setItem(KEY, JSON.stringify(left));
  return left;
}

`App.tsx`

import { useEffect, useState } from "react";
import { SafeAreaView, View, Text, TextInput, Image, Pressable, FlatList, Alert, StyleSheet } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { Entry, addEntry, loadEntries, removeEntry } from "./journal";

export default function App() {
  const [entries, setEntries] = useState<Entry[]>([]);
  const [caption, setCaption] = useState("");

  useEffect(() => {
    loadEntries().then(setEntries);
  }, []);

  const add = async (fromCamera: boolean) => {
    const permission = fromCamera
      ? await ImagePicker.requestCameraPermissionsAsync()
      : await ImagePicker.requestMediaLibraryPermissionsAsync();
    if (!permission.granted) {
      Alert.alert("Permission needed", "Please allow access in Settings to add photos.");
      return;
    }
    const launch = fromCamera ? ImagePicker.launchCameraAsync : ImagePicker.launchImageLibraryAsync;
    const result = await launch({ mediaTypes: ["images"], quality: 0.7 });
    if (result.canceled) return;
    setEntries(await addEntry(result.assets[0].uri, caption.trim() || "Untitled"));
    setCaption("");
  };

  return (
    <SafeAreaView style={styles.screen}>
      <Text style={styles.title}>Photo Journal</Text>
      <TextInput style={styles.input} value={caption} onChangeText={setCaption} placeholder="Caption for the next photo" />
      <View style={styles.row}>
        <Pressable style={styles.button} onPress={() => add(false)}>
          <Text style={styles.buttonText}>Choose photo</Text>
        </Pressable>
        <Pressable style={styles.button} onPress={() => add(true)}>
          <Text style={styles.buttonText}>Take photo</Text>
        </Pressable>
      </View>

      <FlatList
        data={entries}
        keyExtractor={(e) => e.id}
        ListEmptyComponent={<Text style={styles.empty}>No photos yet.</Text>}
        renderItem={({ item }) => (
          <View style={styles.card}>
            <Image source={{ uri: item.uri }} style={styles.photo} />
            <View style={styles.cardRow}>
              <View style={{ flex: 1 }}>
                <Text style={styles.caption}>{item.caption}</Text>
                <Text style={styles.date}>{new Date(item.createdAt).toLocaleDateString()}</Text>
              </View>
              <Pressable onPress={async () => setEntries(await removeEntry(item.id))}>
                <Text style={styles.del}>Delete</Text>
              </Pressable>
            </View>
          </View>
        )}
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 20, backgroundColor: "#f5f6fa" },
  title: { fontSize: 28, fontWeight: "700", marginBottom: 12 },
  input: { backgroundColor: "#fff", borderRadius: 10, padding: 12, borderWidth: 1, borderColor: "#d0d4dc" },
  row: { flexDirection: "row", gap: 8, marginVertical: 12 },
  button: { flex: 1, backgroundColor: "#3b5bdb", padding: 12, borderRadius: 10, alignItems: "center" },
  buttonText: { color: "#fff", fontWeight: "600" },
  empty: { textAlign: "center", color: "#888", marginTop: 30 },
  card: { backgroundColor: "#fff", borderRadius: 12, marginBottom: 12, overflow: "hidden" },
  photo: { width: "100%", height: 200 },
  cardRow: { flexDirection: "row", alignItems: "center", padding: 12 },
  caption: { fontSize: 16, fontWeight: "500" },
  date: { color: "#777", marginTop: 2 },
  del: { color: "#d6336c" },
});

How it works

  • add first asks for permission, then opens the picker or the camera. If the user cancels, nothing happens.
  • addEntry copies the temporary photo into the photos/ folder with a new name and saves the entry list.
  • The list shows each photo with its caption and date. Delete removes the file and the entry.
  • The camera works on a real phone. Emulators may only have a simulated camera.

Expected output

The screen shows Photo Journal, a caption box and two buttons, Choose photo and Take photo. Tap Choose photo: the system asks permission the first time. Allow it, pick a photo and it appears as a card with your caption and today's date. Add another with the camera. Close and reopen the app: both entries are still there with their photos. Delete one and it disappears. If you deny permission, the app shows an alert and keeps working.

Your task

  1. Build the app on a real phone and add one photo from the library and one from the camera.
  2. Deny permission once and read the alert. Then allow it in the phone's Settings and try again.
  3. Tap a photo to show it full screen in a Modal.
  4. Add a search box that filters entries by caption.

How to check it worked

  • Photos remain after the app restarts.
  • Denied permission produces a message, not a crash.
  • Deleting an entry also removes its file.

Frequently asked questions

Why copy the photo?

The picker gives a temporary address that the system may clear. Copying it into the app's document folder keeps it as long as the app is installed.

What if the user denies permission forever?

On many systems the request cannot be shown again. Explain what the feature needs and offer a button that opens the app's settings using Linking.openSettings().

Does the camera work in Expo Go?

Yes on a real phone for the picker and camera used here. Some libraries need a development build instead of Expo Go, and the Expo docs say so on each library page.