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
requestCameraPermissionsAsyncorrequestMediaLibraryPermissionsAsyncand checkgranted. Explain, withAlert, 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
- Use a blank Expo TypeScript project.
- Install the libraries:
npx expo install expo-image-picker expo-file-system @react-native-async-storage/async-storage. - Add the permission texts to
app.json(below). - Add
journal.tsand replaceApp.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
addfirst asks for permission, then opens the picker or the camera. If the user cancels, nothing happens.addEntrycopies the temporary photo into thephotos/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
- Build the app on a real phone and add one photo from the library and one from the camera.
- Deny permission once and read the alert. Then allow it in the phone's Settings and try again.
- Tap a photo to show it full screen in a Modal.
- 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.