Build a Media Tracker with AI Roasts

Build a React Native app that tracks media and roasts your taste with Gemini AI.

Introduction

30 Second Summary

Everyone has that one show, movie, or album they love that their friends roast them for. What if you could build an app that does the roasting for you?

In this project, you will build a React Native mobile app that lets you search for movies, TV shows, anime, and music albums, organize them into custom lists, and get an AI-generated roast of your taste.

What You'll Build

Roast My Taste is a dark-themed, image-rich mobile app built with Expo and React Native that integrates multiple APIs for media search and AI-powered roast generation.

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

  • A multi-screen mobile app running on your Android phone with tab navigation, a polished dark theme, and search integration with TMDB and iTunes APIs for browsing movies, TV shows, anime, and music with poster art.
  • A custom list system with local persistence where you create unlimited named collections and add media with a single tap.
  • An AI roast feature powered by Google Gemini that analyzes your media taste and delivers a personalized burn.
  • Secret Mission: Add a roast history feature that saves past roasts so you can revisit your best burns.

Are there any prerequisites?

This project assumes you're familiar with React basics like components, hooks, and state. You'll also need JavaScript fundamentals and Node.js installed on your computer.

Set Up the Expo Project

Every React Native app starts with a project scaffold. This gives you the folder structure, configuration files, and development tools you need to build and test on a real device.

In this step, you'll use Expo's project creator to scaffold an app with TypeScript and file-based navigation already configured. You'll also install a local storage package and confirm everything runs on your Android phone.

In this step, get ready to:
  • Scaffold the RoastMyTaste project using Expo's CLI tool.
  • Install the expo-sqlite storage package.
  • Verify the app runs on your Android phone via Expo Go.
Create the project and run it on your phone

You'll run a series of terminal commands to create, configure, and launch your project. By the end, you'll see the default Expo app running live on your phone.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Expo project by running this command:
npx create-expo-app@latest RoastMyTaste

What does this command do?

npx create-expo-app@latest RoastMyTaste downloads and runs Expo's project scaffolding tool. It creates a RoastMyTaste folder on your Desktop containing a fully configured app with Expo Router (file-based navigation) and TypeScript.

Since the project pins to SDK 55, this is the version that gets scaffolded. SDK 55 is the latest version compatible with Expo Go on physical devices.

Prompted to install a package?

If your terminal asks "Need to install the following packages" and shows "Ok to proceed? (y)", type y and press Enter. This is normal for first-time npx usage.

  • Confirm the project folder was created by running:
ls
  • You should see RoastMyTaste listed in the output.

Don't see the RoastMyTaste folder?

  • Make sure you ran cd ~/Desktop first. If you skipped that, the folder might be in your home directory instead.
  • Check that Node.js is installed and meets the minimum version (20.19.4+). Run node --version to confirm.
  • If you see a network error, check your internet connection and try again.

Help me troubleshoot this

  • Move into your new project folder by running:
cd RoastMyTaste
  • Install the local storage package by running:
npx expo install expo-sqlite

Why expo-sqlite?

Your app needs to save custom lists and media items locally on the phone. The expo-sqlite package includes a key-value store module that works like a simple local database. It persists data between app sessions without needing a server.

  • You should see output confirming the package was added to your project. Look for a success message mentioning expo-sqlite.

Install command failed?

  • Make sure you're inside the RoastMyTaste folder. Run pwd to check your current directory.
  • If you see permission errors, try running the command again. Temporary network issues can also cause install failures.

Help me fix this install error

  • Start your development server by running:
npx expo start

What happens when the dev server starts?

Your terminal displays a large QR code along with keyboard shortcuts (like a for Android and r to reload). The server stays running and watches for file changes. Keep this terminal open for the rest of the project.

  • Open the Expo Go app on your Android phone.
  • Tap Scan QR code and point your phone's camera at the QR code in your terminal.
  • Wait for the app to build and load on your phone. This may take 30-60 seconds the first time.

You should see the default Expo template app appear on your phone's screen. It displays a welcome message and instructions for editing the app.

App not loading on your phone?

  • Make sure your phone and computer are on the same Wi-Fi network. Expo Go connects to the dev server over your local network.
  • If the QR code scan doesn't work, try pressing r in the terminal to reload, then scan again.
  • Check that no firewall is blocking port 8081 on your computer.

Help me connect my phone to the dev server

Your project is scaffolded, your storage package is installed, and your phone is connected to the dev server. Next up, you'll build out the tab navigation, data layer, and dark theme that give this app its structure.

Build the Navigation and Data Layer

Your Expo project is running on your phone, but it's still showing the default template screens. Before building any features, you need two things: a navigation structure so users can move between screens, and a data layer to store their media lists locally.

In this step, you'll replace the default template with a custom three-tab layout using Expo Router's file-based navigation. You'll also create the TypeScript types and storage utilities that the rest of the app depends on.

In this step, get ready to:
  • Set up three-tab navigation with dark theme styling.
  • Create TypeScript interfaces and storage functions for media lists.
Set up the tab layout with Expo Router

Expo Router uses file-based routing. Each file inside the app/ directory becomes a screen, and folders wrapped in parentheses like (tabs) create route groups without affecting the URL.

  • Clear the default template's src directory and create your new file structure by running these commands in your terminal:
rm -rf src
mkdir -p "app/(tabs)"
mkdir -p app/list
mkdir -p lib
  • Create the empty files for your app screens and utilities by running:
touch app/_layout.tsx "app/(tabs)/_layout.tsx" "app/(tabs)/index.tsx" "app/(tabs)/search.tsx" "app/(tabs)/roast.tsx" "app/list/[id].tsx"
touch lib/types.ts lib/storage.ts lib/api.ts
  • Confirm all files were created by running:
ls app app/(tabs) app/list lib

You should see _layout.tsx in app/, all four screen files in app/(tabs)/, [id].tsx in app/list/, and three files in lib/.

Seeing a 'No such file or directory' error?

Make sure you're running these commands from inside the RoastMyTaste folder. Run pwd to check your current directory.

help me fix my directory structure

Now you'll fill in each file. Start with the root layout, which wraps the entire app.

  • Open app/_layout.tsx in your editor and paste the following code:
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';

export default function RootLayout() {
  return (
    <>
      <StatusBar style="light" />
      <Stack
        screenOptions={{
          headerStyle: { backgroundColor: '#1a1a2e' },
          headerTintColor: '#ffffff',
          contentStyle: { backgroundColor: '#0f0f1a' },
        }}
      >
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen name="list/[id]" options={{ title: 'List Details' }} />
      </Stack>
    </>
  );
}

What does this code do?

  • StatusBar style="light" makes the phone's status bar text white so it's visible against the dark background.
  • Stack creates a navigation stack where screens slide on top of each other. The (tabs) group hides its header since tabs have their own.
  • The list/[id] screen is a dynamic route. The brackets mean the id portion of the URL changes depending on which list the user taps.
  • screenOptions applies the dark theme colors globally across all screens in the stack.
  • Save app/_layout.tsx.
  • Open app/(tabs)/_layout.tsx and paste this code to configure the three tabs:
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarStyle: { backgroundColor: '#1a1a2e', borderTopColor: '#2a2a4e' },
        tabBarActiveTintColor: '#e94560',
        tabBarInactiveTintColor: '#8888aa',
        headerStyle: { backgroundColor: '#1a1a2e' },
        headerTintColor: '#ffffff',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Library',
          tabBarIcon: ({ color, size }) => <Ionicons name="library" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Search',
          tabBarIcon: ({ color, size }) => <Ionicons name="search" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="roast"
        options={{
          title: 'Roast Me',
          tabBarIcon: ({ color, size }) => <Ionicons name="flame" size={size} color={color} />,
        }}
      />
    </Tabs>
  );
}

What does this code do?

  • Tabs creates a tab bar at the bottom of the screen. Each Tabs.Screen maps to a file in the (tabs) folder.
  • tabBarActiveTintColor: '#e94560' is the red accent that highlights the active tab.
  • Ionicons from @expo/vector-icons provides the icon set. Each tab gets a distinct icon (library, search, flame).
  • Save app/(tabs)/_layout.tsx.

The tabs need screens to render. You'll add placeholder content for now and build them out in later steps.

  • Open app/(tabs)/index.tsx and paste this placeholder:
import { View, Text, StyleSheet } from 'react-native';

export default function LibraryScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Library</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a', justifyContent: 'center', alignItems: 'center' },
  text: { color: '#fff', fontSize: 18 },
});
  • Open app/(tabs)/search.tsx and paste:
import { View, Text, StyleSheet } from 'react-native';

export default function SearchScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Search</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a', justifyContent: 'center', alignItems: 'center' },
  text: { color: '#fff', fontSize: 18 },
});
  • Open app/(tabs)/roast.tsx and paste:
import { View, Text, StyleSheet } from 'react-native';

export default function RoastScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Roast Me</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a', justifyContent: 'center', alignItems: 'center' },
  text: { color: '#fff', fontSize: 18 },
});
  • Open app/list/[id].tsx and paste:
import { View, Text, StyleSheet } from 'react-native';

export default function ListDetailScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>List Details</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a', justifyContent: 'center', alignItems: 'center' },
  text: { color: '#fff', fontSize: 18 },
});
  • Save all four files.
  • Check your phone. The app should now show a dark background with three tabs at the bottom: Library, Search, and Roast Me.

What is file-based routing?

In Expo Router, you don't write code to wire up navigation. Instead, the folder structure inside app/ IS your navigation. A (tabs) folder with a _layout.tsx that returns Tabs automatically creates tab navigation.

Each .tsx file inside (tabs)/ becomes a tab screen. The index.tsx file is the default route that loads first.

Not seeing the tabs on your phone?

If the app shows a blank screen or error, make sure you deleted the entire src folder. Expo Router checks src/app first and will ignore your app/ directory if src/app exists.

Also check that your filenames match exactly, including the parentheses in (tabs) and brackets in [id].tsx.

help me fix my tab navigation

Create types and storage utilities

Every media item your app tracks needs a consistent shape. TypeScript interfaces define that shape, and storage utilities handle saving lists to the phone's local database.

  • Open lib/types.ts and paste the type definitions:
export type MediaCategory = 'movie' | 'tv' | 'anime' | 'music';

export interface MediaItem {
  id: string;
  title: string;
  category: MediaCategory;
  posterUrl: string | null;
  year: string;
}

export interface MediaList {
  id: string;
  name: string;
  items: MediaItem[];
  createdAt: number;
}

What do these types represent?

  • MediaCategory limits categories to four valid options. TypeScript will catch typos at compile time.
  • MediaItem is a single movie, TV show, anime, or album. The posterUrl can be null because not every search result has artwork.
  • MediaList is a named collection (like "Peak Fiction") containing multiple MediaItem entries.
  • Save lib/types.ts.
  • Open lib/storage.ts and paste the storage utility functions:
import Storage from 'expo-sqlite/kv-store';
import { MediaItem, MediaList } from './types';

const LISTS_KEY = 'roast_my_taste_lists';

export async function loadLists(): Promise<MediaList[]> {
  const raw = await Storage.getItem(LISTS_KEY);
  if (!raw) return [];
  return JSON.parse(raw);
}

async function saveLists(lists: MediaList[]): Promise<void> {
  await Storage.setItem(LISTS_KEY, JSON.stringify(lists));
}

What does this code do?

  • Storage from expo-sqlite/kv-store provides a simple key-value store backed by SQLite on the device. Data persists even after closing the app.
  • loadLists reads the stored JSON string and parses it back into an array. If nothing is stored yet, it returns an empty array.
  • saveLists is a private helper that serializes the entire lists array to a single JSON string and stores it.
  • Below the saveLists function in lib/storage.ts, add the CRUD operations:
export async function createList(name: string): Promise<MediaList> {
  const lists = await loadLists();
  const newList: MediaList = {
    id: Date.now().toString(),
    name,
    items: [],
    createdAt: Date.now(),
  };
  lists.push(newList);
  await saveLists(lists);
  return newList;
}

export async function addMediaToList(listId: string, item: MediaItem): Promise<void> {
  const lists = await loadLists();
  const list = lists.find((l) => l.id === listId);
  if (list) {
    const alreadyExists = list.items.some((i) => i.id === item.id);
    if (!alreadyExists) {
      list.items.push(item);
      await saveLists(lists);
    }
  }
}

export async function removeMediaFromList(listId: string, itemId: string): Promise<void> {
  const lists = await loadLists();
  const list = lists.find((l) => l.id === listId);
  if (list) {
    list.items = list.items.filter((i) => i.id !== itemId);
    await saveLists(lists);
  }
}

What do these functions do?

  • createList generates a unique ID using the current timestamp, adds the new list to storage, and returns it.
  • addMediaToList finds the target list and only adds the item if it's not already there. This prevents duplicates.
  • removeMediaFromList filters out the item by ID, then saves the updated list back to storage.
  • Save lib/storage.ts.
  • Open lib/api.ts and paste the placeholder API functions:
import { MediaItem, MediaCategory } from './types';

const TMDB_API_KEY = 'your-tmdb-api-key-here';
const GEMINI_API_KEY = 'your-gemini-api-key-here';

export async function searchMedia(query: string, category: MediaCategory): Promise<MediaItem[]> {
  return [];
}

export async function generateRoast(mediaNames: string[], listName: string): Promise<string> {
  return '';
}

Why are these functions empty?

These are placeholder stubs. They define the function signatures so other files can import them without errors. You'll implement the actual API calls in the next steps.

The API key constants are where you'll paste your real keys later. For now, the placeholder strings prevent TypeScript compile errors.

  • Save lib/api.ts.
  • Check your phone. The app should still show the three tabs without any errors. If you see a red error screen, double-check your import paths.

Seeing a red error screen?

The most common cause is a typo in an import path. Make sure lib/storage.ts imports from './types' (with the dot-slash) and lib/api.ts does the same.

Also verify that expo-sqlite is installed. Run npx expo install expo-sqlite again if needed.

help me debug my red error screen

✔️ Awesome, I've got everything!

Great. Make sure all files are saved before moving on.

ⓧ I'd like to double check the full code

Here is each file in full. Compare yours against these:

import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';

export default function RootLayout() {
  return (
    <>
      <StatusBar style="light" />
      <Stack
        screenOptions={{
          headerStyle: { backgroundColor: '#1a1a2e' },
          headerTintColor: '#ffffff',
          contentStyle: { backgroundColor: '#0f0f1a' },
        }}
      >
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen name="list/[id]" options={{ title: 'List Details' }} />
      </Stack>
    </>
  );
}
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarStyle: { backgroundColor: '#1a1a2e', borderTopColor: '#2a2a4e' },
        tabBarActiveTintColor: '#e94560',
        tabBarInactiveTintColor: '#8888aa',
        headerStyle: { backgroundColor: '#1a1a2e' },
        headerTintColor: '#ffffff',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Library',
          tabBarIcon: ({ color, size }) => <Ionicons name="library" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Search',
          tabBarIcon: ({ color, size }) => <Ionicons name="search" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="roast"
        options={{
          title: 'Roast Me',
          tabBarIcon: ({ color, size }) => <Ionicons name="flame" size={size} color={color} />,
        }}
      />
    </Tabs>
  );
}
export type MediaCategory = 'movie' | 'tv' | 'anime' | 'music';

export interface MediaItem {
  id: string;
  title: string;
  category: MediaCategory;
  posterUrl: string | null;
  year: string;
}

export interface MediaList {
  id: string;
  name: string;
  items: MediaItem[];
  createdAt: number;
}
import Storage from 'expo-sqlite/kv-store';
import { MediaItem, MediaList } from './types';

const LISTS_KEY = 'roast_my_taste_lists';

export async function loadLists(): Promise<MediaList[]> {
  const raw = await Storage.getItem(LISTS_KEY);
  if (!raw) return [];
  return JSON.parse(raw);
}

async function saveLists(lists: MediaList[]): Promise<void> {
  await Storage.setItem(LISTS_KEY, JSON.stringify(lists));
}

export async function createList(name: string): Promise<MediaList> {
  const lists = await loadLists();
  const newList: MediaList = {
    id: Date.now().toString(),
    name,
    items: [],
    createdAt: Date.now(),
  };
  lists.push(newList);
  await saveLists(lists);
  return newList;
}

export async function addMediaToList(listId: string, item: MediaItem): Promise<void> {
  const lists = await loadLists();
  const list = lists.find((l) => l.id === listId);
  if (list) {
    const alreadyExists = list.items.some((i) => i.id === item.id);
    if (!alreadyExists) {
      list.items.push(item);
      await saveLists(lists);
    }
  }
}

export async function removeMediaFromList(listId: string, itemId: string): Promise<void> {
  const lists = await loadLists();
  const list = lists.find((l) => l.id === listId);
  if (list) {
    list.items = list.items.filter((i) => i.id !== itemId);
    await saveLists(lists);
  }
}
import { MediaItem, MediaCategory } from './types';

const TMDB_API_KEY = 'your-tmdb-api-key-here';
const GEMINI_API_KEY = 'your-gemini-api-key-here';

export async function searchMedia(query: string, category: MediaCategory): Promise<MediaItem[]> {
  return [];
}

export async function generateRoast(mediaNames: string[], listName: string): Promise<string> {
  return '';
}

Your navigation structure and data layer are in place. Next up, you'll connect the search screen to real APIs and display movie posters.

Build the Search Screen

Your app has navigation and data storage ready. Now it needs the core feature that makes it useful: searching for movies, TV shows, anime, and music.

In this step, you will build a search screen that fetches results from two real APIs and displays them as poster cards. You will also add the ability to save any result into a custom list.

In this step, get ready to:
  • Build a search input with debounced queries and category filter chips.
  • Integrate TMDB and iTunes Search APIs to return real media results with poster art.
  • Add an "Add to List" modal so users can save any result to a custom list.
Build the search UI with category filters

The search screen needs three visual layers: a text input for queries, tappable chips to filter by media type, and a grid of poster cards showing results.

To avoid hammering the API on every keystroke, you will debounce the search. This means waiting 500 milliseconds after the user stops typing before firing a request.

  • Create the file app/(tabs)/search.tsx and add the following code:
import { View, Text, TextInput, FlatList, TouchableOpacity, StyleSheet, Image, ActivityIndicator } from 'react-native';
import { useState, useEffect } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { searchMedia } from '../../lib/api';
import { MediaItem, MediaCategory } from '../../lib/types';

// Define the four media categories the user can filter by
const CATEGORIES: { label: string; value: MediaCategory }[] = [
  { label: 'Movies', value: 'movie' },
  { label: 'TV Shows', value: 'tv' },
  { label: 'Anime', value: 'anime' },
  { label: 'Music', value: 'music' },
];

export default function SearchScreen() {
  const [query, setQuery] = useState('');
  const [category, setCategory] = useState<MediaCategory>('movie');
  const [results, setResults] = useState<MediaItem[]>([]);
  const [loading, setLoading] = useState(false);

  // Wait 500ms after user stops typing before searching
  useEffect(() => {
    if (query.length < 2) {
      setResults([]);
      return;
    }
    const timer = setTimeout(async () => {
      setLoading(true);
      const data = await searchMedia(query, category);
      setResults(data);
      setLoading(false);
    }, 500);
    return () => clearTimeout(timer);
  }, [query, category]);

  return (
    <View style={styles.container}>
      <View style={styles.searchRow}>
        <TextInput
          style={styles.searchInput}
          placeholder="Search movies, TV, anime, music..."
          placeholderTextColor="#666"
          value={query}
          onChangeText={setQuery}
        />
      </View>
      <View style={styles.categories}>
        {CATEGORIES.map((cat) => (
          <TouchableOpacity
            key={cat.value}
            style={[styles.chip, category === cat.value && styles.chipActive]}
            onPress={() => setCategory(cat.value)}
          >
            <Text style={[styles.chipText, category === cat.value && styles.chipTextActive]}>
              {cat.label}
            </Text>
          </TouchableOpacity>
        ))}
      </View>

      {loading && <ActivityIndicator color="#e94560" style={{ marginTop: 20 }} />}

      <FlatList
        data={results}
        keyExtractor={(item) => item.id}
        numColumns={2}
        columnWrapperStyle={styles.row}
        contentContainerStyle={styles.resultsContent}
        renderItem={({ item }) => (
          <TouchableOpacity style={styles.resultCard}>
            {item.posterUrl ? (
              <Image source={{ uri: item.posterUrl }} style={styles.poster} />
            ) : (
              <View style={[styles.poster, styles.noPoster]}>
                <Ionicons name="image-outline" size={24} color="#555" />
              </View>
            )}
            <Text style={styles.resultTitle} numberOfLines={2}>{item.title}</Text>
            <Text style={styles.resultYear}>{item.year}</Text>
          </TouchableOpacity>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a' },
  searchRow: { padding: 16, paddingBottom: 8 },
  searchInput: { backgroundColor: '#1a1a2e', color: '#fff', borderRadius: 12, padding: 14, fontSize: 16 },
  categories: { flexDirection: 'row', paddingHorizontal: 16, gap: 8, marginBottom: 8 },
  chip: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, backgroundColor: '#1a1a2e' },
  chipActive: { backgroundColor: '#e94560' },
  chipText: { color: '#8888aa', fontSize: 13, fontWeight: '500' },
  chipTextActive: { color: '#fff' },
  row: { justifyContent: 'space-between', paddingHorizontal: 16 },
  resultsContent: { paddingBottom: 20 },
  resultCard: { width: '48%', marginBottom: 16, borderRadius: 12, overflow: 'hidden', backgroundColor: '#1a1a2e' },
  poster: { width: '100%', height: 220, resizeMode: 'cover' },
  noPoster: { justifyContent: 'center', alignItems: 'center', backgroundColor: '#2a2a4e' },
  resultTitle: { color: '#fff', fontSize: 13, padding: 8, paddingBottom: 2, fontWeight: '500' },
  resultYear: { color: '#8888aa', fontSize: 11, paddingHorizontal: 8, paddingBottom: 8 },
});

What does this code do?

This component sets up the search screen with three key pieces:

  • A useEffect hook that debounces search queries. It sets a 500ms timer every time the query or category changes. If the user types again before the timer fires, the old timer is cancelled. This prevents flooding the API with requests on every keystroke.
  • A row of CATEGORIES chips rendered from an array. The active chip gets a red background to show which filter is selected.
  • A 2-column FlatList that renders each result as a card with a poster image, title, and year. If no poster exists, a placeholder icon shows instead.
  • Save app/(tabs)/search.tsx.
  • Check your phone. Tap the Search tab. You should see a text input at the top with four category chips below it (Movies, TV Shows, Anime, Music).

Don't see the search bar?

  • Make sure the file is saved at exactly app/(tabs)/search.tsx. The filename must match the tab name defined in _layout.tsx.
  • Check for any red error banner on your phone. A missing import or typo will prevent the screen from rendering.

Help me debug why my Search tab shows an error instead of the search bar.

Integrate TMDB and iTunes APIs

The search UI is ready, but typing a query returns nothing because searchMedia still returns an empty array. Time to connect it to real data sources.

You will use the TMDB API for movies, TV shows, and anime. For music albums, you will use the iTunes Search API which requires no authentication at all.

  • Open lib/api.ts and replace the entire file contents with the following code:
import { MediaItem, MediaCategory } from './types';

const TMDB_API_KEY = 'your-tmdb-api-key-here';
const GEMINI_API_KEY = 'your-gemini-api-key-here';

// Route search to the right API based on category
export async function searchMedia(query: string, category: MediaCategory): Promise<MediaItem[]> {
  if (category === 'music') {
    return searchMusic(query);
  }
  return searchTMDB(query, category);
}

async function searchTMDB(query: string, category: MediaCategory): Promise<MediaItem[]> {
  // Movies use 'movie' endpoint, TV and anime both use 'tv'
  const type = category === 'movie' ? 'movie' : 'tv';
  const url = `https://api.themoviedb.org/3/search/${type}?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(query)}&page=1`;

  try {
    const response = await fetch(url);
    const data = await response.json();

    let results = data.results || [];

    // Anime is TV filtered to genre_id 16 (Animation)
    if (category === 'anime') {
      results = results.filter((item: any) => item.genre_ids?.includes(16));
    }

    return results.slice(0, 10).map((item: any) => ({
      id: `tmdb-${item.id}`,
      title: item.title || item.name || 'Unknown',
      category,
      posterUrl: item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : null,
      year: (item.release_date || item.first_air_date || '').split('-')[0] || '',
    }));
  } catch {
    return [];
  }
}

async function searchMusic(query: string): Promise<MediaItem[]> {
  const url = `https://itunes.apple.com/search?term=${encodeURIComponent(query)}&media=music&entity=album&limit=10`;

  try {
    const response = await fetch(url);
    const data = await response.json();

    return (data.results || []).map((item: any) => ({
      id: `itunes-${item.collectionId}`,
      title: `${item.collectionName} - ${item.artistName}`,
      category: 'music' as MediaCategory,
      // Replace 100x100 thumbnail with 600x600 high-res version
      posterUrl: item.artworkUrl100 ? item.artworkUrl100.replace('100x100', '600x600') : null,
      year: item.releaseDate ? new Date(item.releaseDate).getFullYear().toString() : '',
    }));
  } catch {
    return [];
  }
}

export async function generateRoast(mediaNames: string[], listName: string): Promise<string> {
  return '';
}

What does this code do?

The searchMedia function now routes queries to the right API. If the category is music, it calls the iTunes Search API. Everything else goes through TMDB.

  • searchTMDB hits api.themoviedb.org with your API key and the query. For anime, it searches TV shows then filters to only results with genre_id 16 (Animation). Poster images come from TMDB's CDN at the w500 size.
  • searchMusic hits the iTunes Search API for album results. The artworkUrl100 field returns a tiny 100x100 thumbnail, so we swap that string for 600x600 to get high-resolution album art.
  • Both functions catch errors and return an empty array so the app never crashes from a network failure.
  • Replace your-tmdb-api-key-here with the TMDB API key you created earlier.
  • Save lib/api.ts.
  • On your phone, tap the Search tab and type Attack on Titan with the Anime chip selected. You should see poster cards appear after a brief pause.
  • Switch to the Music chip and type Kendrick Lamar. Album covers should appear.

No results showing up?

  • Check that you replaced the placeholder with your actual TMDB API key. The string your-tmdb-api-key-here will not work.
  • Make sure you typed at least 2 characters. The debounce logic skips searches for queries shorter than 2 characters.
  • If you see a red error, check that the import path ../../lib/api in search.tsx is correct.

Help me figure out why my TMDB search returns no results in my React Native app.

Add the "Add to List" modal

Users can search and see results, but they cannot save anything yet. The final piece is a bottom sheet modal that appears when a user taps a result card. From the modal they can pick an existing list or create a new one on the spot.

  • In app/(tabs)/search.tsx, find the import line at the top of the file:
import { View, Text, TextInput, FlatList, TouchableOpacity, StyleSheet, Image, ActivityIndicator } from 'react-native';
  • Replace it with this updated import that adds Modal:
import { View, Text, TextInput, FlatList, TouchableOpacity, StyleSheet, Image, Modal, ActivityIndicator } from 'react-native';
  • Find the existing import for searchMedia:
import { searchMedia } from '../../lib/api';
import { MediaItem, MediaCategory } from '../../lib/types';
  • Replace those two lines with these expanded imports that bring in storage functions and the MediaList type:
import { searchMedia } from '../../lib/api';
import { loadLists, addMediaToList, createList } from '../../lib/storage';
import { MediaItem, MediaList, MediaCategory } from '../../lib/types';
  • Find the state declarations inside SearchScreen. After the loading state line, add three new state variables:
  const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null);
  const [lists, setLists] = useState<MediaList[]>([]);
  const [newListName, setNewListName] = useState('');
  • After the useEffect block, add three handler functions:
  // Load existing lists and show the modal
  const handleItemPress = async (item: MediaItem) => {
    const currentLists = await loadLists();
    setLists(currentLists);
    setSelectedItem(item);
  };

  // Add the selected item to a chosen list
  const handleAddToList = async (listId: string) => {
    if (selectedItem) {
      await addMediaToList(listId, selectedItem);
      setSelectedItem(null);
    }
  };

  // Create a brand new list and add the item to it
  const handleCreateAndAdd = async () => {
    if (newListName.trim() && selectedItem) {
      const newList = await createList(newListName.trim());
      await addMediaToList(newList.id, selectedItem);
      setNewListName('');
      setSelectedItem(null);
    }
  };

What do these handlers do?

  • handleItemPress loads the latest lists from storage, then opens the modal by setting selectedItem. The modal's visibility is tied to whether selectedItem is null or not.
  • handleAddToList saves the media item into the chosen list, then closes the modal by setting selectedItem back to null.
  • handleCreateAndAdd creates a new list with the typed name, immediately adds the item, then closes the modal. This lets users create and save in one tap.
  • Find the <TouchableOpacity style={styles.resultCard}> line inside the FlatList's renderItem. Add an onPress prop so the line looks like this:
          <TouchableOpacity style={styles.resultCard} onPress={() => handleItemPress(item)}>
  • Now add the Modal component. Find the closing </View> at the very end of the return statement (just before the closing parenthesis of return). Add this Modal block directly above that closing </View>:
      <Modal visible={!!selectedItem} transparent animationType="slide">
        <View style={styles.modalOverlay}>
          <View style={styles.modalContent}>
            <Text style={styles.modalTitle}>Add to List</Text>
            <Text style={styles.modalSubtitle}>{selectedItem?.title}</Text>
            {lists.map((list) => (
              <TouchableOpacity
                key={list.id}
                style={styles.listOption}
                onPress={() => handleAddToList(list.id)}
              >
                <Text style={styles.listOptionText}>{list.name}</Text>
                <Text style={styles.listOptionCount}>{list.items.length} items</Text>
              </TouchableOpacity>
            ))}
            <View style={styles.newListRow}>
              <TextInput
                style={styles.newListInput}
                placeholder="New list name..."
                placeholderTextColor="#666"
                value={newListName}
                onChangeText={setNewListName}
              />
              <TouchableOpacity style={styles.newListBtn} onPress={handleCreateAndAdd}>
                <Ionicons name="add" size={20} color="#fff" />
              </TouchableOpacity>
            </View>
            <TouchableOpacity onPress={() => setSelectedItem(null)} style={styles.cancelBtn}>
              <Text style={styles.cancelBtnText}>Cancel</Text>
            </TouchableOpacity>
          </View>
        </View>
      </Modal>

How does the modal work?

The Modal component becomes visible when selectedItem is not null. It slides up from the bottom thanks to animationType="slide".

Inside, it lists every existing list as a tappable row. Below the list options, a text input lets the user type a new list name and tap the + button to create it instantly. The Cancel button sets selectedItem back to null, which hides the modal.

  • Finally, add the modal styles. Find the StyleSheet.create block and add these entries after the resultYear style:
  modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'flex-end' },
  modalContent: { backgroundColor: '#1a1a2e', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 24, maxHeight: '60%' },
  modalTitle: { color: '#fff', fontSize: 18, fontWeight: '600' },
  modalSubtitle: { color: '#8888aa', fontSize: 14, marginTop: 4, marginBottom: 16 },
  listOption: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: '#2a2a4e' },
  listOptionText: { color: '#fff', fontSize: 15 },
  listOptionCount: { color: '#8888aa', fontSize: 13 },
  newListRow: { flexDirection: 'row', marginTop: 16, gap: 8 },
  newListInput: { flex: 1, backgroundColor: '#0f0f1a', color: '#fff', borderRadius: 8, padding: 12, fontSize: 14 },
  newListBtn: { backgroundColor: '#e94560', width: 44, borderRadius: 8, justifyContent: 'center', alignItems: 'center' },
  cancelBtn: { marginTop: 16, alignItems: 'center', padding: 12 },
  cancelBtnText: { color: '#8888aa', fontSize: 14 },
  • Save app/(tabs)/search.tsx.
  • On your phone, search for any movie or show. Tap a result card. A modal should slide up from the bottom showing an "Add to List" header with the item's title.
  • In the modal, type Peak Fiction in the new list input and tap the + button. The modal should close, confirming the item was saved.

Modal not appearing when you tap a card?

  • Check that you added onPress={() => handleItemPress(item)} to the TouchableOpacity inside renderItem. Without it, tapping does nothing.
  • Make sure you imported Modal from react-native in the first import line. A missing import will cause a runtime error.
  • Verify that loadLists, addMediaToList, and createList are all imported from ../../lib/storage.

Help me debug why tapping a search result card doesn't open the Add to List modal.

✔️ Awesome, I've got everything!

Great. Double check you have saved both lib/api.ts and app/(tabs)/search.tsx.

ⓧ I'd like to double check the full code

Here is the full lib/api.ts:

import { MediaItem, MediaCategory } from './types';

const TMDB_API_KEY = 'your-tmdb-api-key-here';
const GEMINI_API_KEY = 'your-gemini-api-key-here';

export async function searchMedia(query: string, category: MediaCategory): Promise<MediaItem[]> {
  if (category === 'music') {
    return searchMusic(query);
  }
  return searchTMDB(query, category);
}

async function searchTMDB(query: string, category: MediaCategory): Promise<MediaItem[]> {
  const type = category === 'movie' ? 'movie' : 'tv';
  const url = `https://api.themoviedb.org/3/search/${type}?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(query)}&page=1`;

  try {
    const response = await fetch(url);
    const data = await response.json();

    let results = data.results || [];

    if (category === 'anime') {
      results = results.filter((item: any) => item.genre_ids?.includes(16));
    }

    return results.slice(0, 10).map((item: any) => ({
      id: `tmdb-${item.id}`,
      title: item.title || item.name || 'Unknown',
      category,
      posterUrl: item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : null,
      year: (item.release_date || item.first_air_date || '').split('-')[0] || '',
    }));
  } catch {
    return [];
  }
}

async function searchMusic(query: string): Promise<MediaItem[]> {
  const url = `https://itunes.apple.com/search?term=${encodeURIComponent(query)}&media=music&entity=album&limit=10`;

  try {
    const response = await fetch(url);
    const data = await response.json();

    return (data.results || []).map((item: any) => ({
      id: `itunes-${item.collectionId}`,
      title: `${item.collectionName} - ${item.artistName}`,
      category: 'music' as MediaCategory,
      posterUrl: item.artworkUrl100 ? item.artworkUrl100.replace('100x100', '600x600') : null,
      year: item.releaseDate ? new Date(item.releaseDate).getFullYear().toString() : '',
    }));
  } catch {
    return [];
  }
}

export async function generateRoast(mediaNames: string[], listName: string): Promise<string> {
  return '';
}

Here is the full app/(tabs)/search.tsx:

import { View, Text, TextInput, FlatList, TouchableOpacity, StyleSheet, Image, Modal, ActivityIndicator } from 'react-native';
import { useState, useEffect } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { searchMedia } from '../../lib/api';
import { loadLists, addMediaToList, createList } from '../../lib/storage';
import { MediaItem, MediaList, MediaCategory } from '../../lib/types';

const CATEGORIES: { label: string; value: MediaCategory }[] = [
  { label: 'Movies', value: 'movie' },
  { label: 'TV Shows', value: 'tv' },
  { label: 'Anime', value: 'anime' },
  { label: 'Music', value: 'music' },
];

export default function SearchScreen() {
  const [query, setQuery] = useState('');
  const [category, setCategory] = useState<MediaCategory>('movie');
  const [results, setResults] = useState<MediaItem[]>([]);
  const [loading, setLoading] = useState(false);
  const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null);
  const [lists, setLists] = useState<MediaList[]>([]);
  const [newListName, setNewListName] = useState('');

  useEffect(() => {
    if (query.length < 2) {
      setResults([]);
      return;
    }
    const timer = setTimeout(async () => {
      setLoading(true);
      const data = await searchMedia(query, category);
      setResults(data);
      setLoading(false);
    }, 500);
    return () => clearTimeout(timer);
  }, [query, category]);

  const handleItemPress = async (item: MediaItem) => {
    const currentLists = await loadLists();
    setLists(currentLists);
    setSelectedItem(item);
  };

  const handleAddToList = async (listId: string) => {
    if (selectedItem) {
      await addMediaToList(listId, selectedItem);
      setSelectedItem(null);
    }
  };

  const handleCreateAndAdd = async () => {
    if (newListName.trim() && selectedItem) {
      const newList = await createList(newListName.trim());
      await addMediaToList(newList.id, selectedItem);
      setNewListName('');
      setSelectedItem(null);
    }
  };

  return (
    <View style={styles.container}>
      <View style={styles.searchRow}>
        <TextInput
          style={styles.searchInput}
          placeholder="Search movies, TV, anime, music..."
          placeholderTextColor="#666"
          value={query}
          onChangeText={setQuery}
        />
      </View>
      <View style={styles.categories}>
        {CATEGORIES.map((cat) => (
          <TouchableOpacity
            key={cat.value}
            style={[styles.chip, category === cat.value && styles.chipActive]}
            onPress={() => setCategory(cat.value)}
          >
            <Text style={[styles.chipText, category === cat.value && styles.chipTextActive]}>
              {cat.label}
            </Text>
          </TouchableOpacity>
        ))}
      </View>

      {loading && <ActivityIndicator color="#e94560" style={{ marginTop: 20 }} />}

      <FlatList
        data={results}
        keyExtractor={(item) => item.id}
        numColumns={2}
        columnWrapperStyle={styles.row}
        contentContainerStyle={styles.resultsContent}
        renderItem={({ item }) => (
          <TouchableOpacity style={styles.resultCard} onPress={() => handleItemPress(item)}>
            {item.posterUrl ? (
              <Image source={{ uri: item.posterUrl }} style={styles.poster} />
            ) : (
              <View style={[styles.poster, styles.noPoster]}>
                <Ionicons name="image-outline" size={24} color="#555" />
              </View>
            )}
            <Text style={styles.resultTitle} numberOfLines={2}>{item.title}</Text>
            <Text style={styles.resultYear}>{item.year}</Text>
          </TouchableOpacity>
        )}
      />

      <Modal visible={!!selectedItem} transparent animationType="slide">
        <View style={styles.modalOverlay}>
          <View style={styles.modalContent}>
            <Text style={styles.modalTitle}>Add to List</Text>
            <Text style={styles.modalSubtitle}>{selectedItem?.title}</Text>
            {lists.map((list) => (
              <TouchableOpacity
                key={list.id}
                style={styles.listOption}
                onPress={() => handleAddToList(list.id)}
              >
                <Text style={styles.listOptionText}>{list.name}</Text>
                <Text style={styles.listOptionCount}>{list.items.length} items</Text>
              </TouchableOpacity>
            ))}
            <View style={styles.newListRow}>
              <TextInput
                style={styles.newListInput}
                placeholder="New list name..."
                placeholderTextColor="#666"
                value={newListName}
                onChangeText={setNewListName}
              />
              <TouchableOpacity style={styles.newListBtn} onPress={handleCreateAndAdd}>
                <Ionicons name="add" size={20} color="#fff" />
              </TouchableOpacity>
            </View>
            <TouchableOpacity onPress={() => setSelectedItem(null)} style={styles.cancelBtn}>
              <Text style={styles.cancelBtnText}>Cancel</Text>
            </TouchableOpacity>
          </View>
        </View>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a' },
  searchRow: { padding: 16, paddingBottom: 8 },
  searchInput: { backgroundColor: '#1a1a2e', color: '#fff', borderRadius: 12, padding: 14, fontSize: 16 },
  categories: { flexDirection: 'row', paddingHorizontal: 16, gap: 8, marginBottom: 8 },
  chip: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, backgroundColor: '#1a1a2e' },
  chipActive: { backgroundColor: '#e94560' },
  chipText: { color: '#8888aa', fontSize: 13, fontWeight: '500' },
  chipTextActive: { color: '#fff' },
  row: { justifyContent: 'space-between', paddingHorizontal: 16 },
  resultsContent: { paddingBottom: 20 },
  resultCard: { width: '48%', marginBottom: 16, borderRadius: 12, overflow: 'hidden', backgroundColor: '#1a1a2e' },
  poster: { width: '100%', height: 220, resizeMode: 'cover' },
  noPoster: { justifyContent: 'center', alignItems: 'center', backgroundColor: '#2a2a4e' },
  resultTitle: { color: '#fff', fontSize: 13, padding: 8, paddingBottom: 2, fontWeight: '500' },
  resultYear: { color: '#8888aa', fontSize: 11, paddingHorizontal: 8, paddingBottom: 8 },
  modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'flex-end' },
  modalContent: { backgroundColor: '#1a1a2e', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 24, maxHeight: '60%' },
  modalTitle: { color: '#fff', fontSize: 18, fontWeight: '600' },
  modalSubtitle: { color: '#8888aa', fontSize: 14, marginTop: 4, marginBottom: 16 },
  listOption: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: '#2a2a4e' },
  listOptionText: { color: '#fff', fontSize: 15 },
  listOptionCount: { color: '#8888aa', fontSize: 13 },
  newListRow: { flexDirection: 'row', marginTop: 16, gap: 8 },
  newListInput: { flex: 1, backgroundColor: '#0f0f1a', color: '#fff', borderRadius: 8, padding: 12, fontSize: 14 },
  newListBtn: { backgroundColor: '#e94560', width: 44, borderRadius: 8, justifyContent: 'center', alignItems: 'center' },
  cancelBtn: { marginTop: 16, alignItems: 'center', padding: 12 },
  cancelBtnText: { color: '#8888aa', fontSize: 14 },
});

Your search screen is fully operational. Users can find any movie, show, anime, or album and save it into custom lists. Next up, you will build the Library screen so users can browse and manage those saved collections.

Build the Library Screen

Your search screen is working and you can add media to lists. But right now, there's no way to actually see those lists or manage them.

In this step, you'll build the Library screen where users see all their collections as visual cards with poster covers. You'll also create a detail view so users can tap into any list and manage its contents.

In this step, get ready to:
  • Build the Library screen with visual list cards and a create-list modal.
  • Build the list detail view with a poster grid and long-press removal.
Display lists and create new ones

The Library tab is the home screen of your app. It needs to show every list the user has created, with the first item's poster as a visual cover image.

  • Replace the contents of app/(tabs)/index.tsx with the following code:
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Image, TextInput, Modal } from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router';
import { useState, useCallback } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { loadLists, createList } from '../../lib/storage';
import { MediaList } from '../../lib/types';

export default function LibraryScreen() {
  const router = useRouter();
  const [lists, setLists] = useState<MediaList[]>([]);
  const [showModal, setShowModal] = useState(false);
  const [newListName, setNewListName] = useState('');

  // Reload lists every time this tab comes into focus
  useFocusEffect(
    useCallback(() => {
      loadLists().then(setLists);
    }, [])
  );

  const handleCreateList = async () => {
    if (newListName.trim()) {
      await createList(newListName.trim());
      setNewListName('');
      setShowModal(false);
      const updated = await loadLists();
      setLists(updated);
    }
  };

  return (
    <View style={styles.container}>
      <FlatList
        data={lists}
        keyExtractor={(item) => item.id}
        numColumns={2}
        columnWrapperStyle={styles.row}
        contentContainerStyle={styles.listContent}
        ListEmptyComponent={
          <View style={styles.emptyState}>
            <Ionicons name="library-outline" size={64} color="#555" />
            <Text style={styles.emptyText}>No lists yet</Text>
            <Text style={styles.emptySubtext}>Create a list and start adding media!</Text>
          </View>
        }
        renderItem={({ item }) => (
          <TouchableOpacity
            style={styles.listCard}
            onPress={() => router.push(`/list/${item.id}`)}
          >
            {item.items.length > 0 && item.items[0].posterUrl ? (
              <Image source={{ uri: item.items[0].posterUrl }} style={styles.listCover} />
            ) : (
              <View style={[styles.listCover, styles.placeholderCover]}>
                <Ionicons name="images-outline" size={32} color="#555" />
              </View>
            )}
            <View style={styles.listInfo}>
              <Text style={styles.listName} numberOfLines={1}>{item.name}</Text>
              <Text style={styles.listCount}>{item.items.length} items</Text>
            </View>
          </TouchableOpacity>
        )}
      />
      {/* Floating action button to create a new list */}
      <TouchableOpacity style={styles.fab} onPress={() => setShowModal(true)}>
        <Ionicons name="add" size={28} color="#fff" />
      </TouchableOpacity>

      {/* Modal for entering a new list name */}
      <Modal visible={showModal} transparent animationType="fade">
        <View style={styles.modalOverlay}>
          <View style={styles.modalContent}>
            <Text style={styles.modalTitle}>New List</Text>
            <TextInput
              style={styles.modalInput}
              placeholder="List name..."
              placeholderTextColor="#666"
              value={newListName}
              onChangeText={setNewListName}
              autoFocus
            />
            <View style={styles.modalButtons}>
              <TouchableOpacity onPress={() => setShowModal(false)} style={styles.modalCancel}>
                <Text style={styles.modalCancelText}>Cancel</Text>
              </TouchableOpacity>
              <TouchableOpacity onPress={handleCreateList} style={styles.modalCreate}>
                <Text style={styles.modalCreateText}>Create</Text>
              </TouchableOpacity>
            </View>
          </View>
        </View>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a' },
  listContent: { padding: 16, paddingBottom: 80 },
  row: { justifyContent: 'space-between' },
  listCard: { width: '48%', marginBottom: 16, borderRadius: 12, overflow: 'hidden', backgroundColor: '#1a1a2e' },
  listCover: { width: '100%', height: 200, resizeMode: 'cover' },
  placeholderCover: { justifyContent: 'center', alignItems: 'center', backgroundColor: '#2a2a4e' },
  listInfo: { padding: 10 },
  listName: { color: '#fff', fontSize: 14, fontWeight: '600' },
  listCount: { color: '#8888aa', fontSize: 12, marginTop: 2 },
  emptyState: { alignItems: 'center', marginTop: 100 },
  emptyText: { color: '#fff', fontSize: 20, fontWeight: '600', marginTop: 16 },
  emptySubtext: { color: '#8888aa', fontSize: 14, marginTop: 8 },
  fab: { position: 'absolute', bottom: 24, right: 24, width: 56, height: 56, borderRadius: 28, backgroundColor: '#e94560', justifyContent: 'center', alignItems: 'center', elevation: 5 },
  modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', alignItems: 'center' },
  modalContent: { backgroundColor: '#1a1a2e', borderRadius: 16, padding: 24, width: '80%' },
  modalTitle: { color: '#fff', fontSize: 18, fontWeight: '600', marginBottom: 16 },
  modalInput: { backgroundColor: '#0f0f1a', color: '#fff', borderRadius: 8, padding: 12, fontSize: 16, marginBottom: 16 },
  modalButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: 12 },
  modalCancel: { padding: 10 },
  modalCancelText: { color: '#8888aa', fontSize: 14 },
  modalCreate: { backgroundColor: '#e94560', paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8 },
  modalCreateText: { color: '#fff', fontSize: 14, fontWeight: '600' },
});

What does this code do?

useFocusEffect reloads the lists every time you switch to this tab. This is important because tabs stay mounted in memory. A regular useEffect would only run once when the component first mounts.

The FlatList renders a 2-column grid of list cards. Each card shows the first item's poster as a cover, or a placeholder icon if the list is empty. The ListEmptyComponent prop displays helpful text when no lists exist yet.

The floating action button (FAB) in the bottom-right corner opens a modal where users type a list name and tap Create. After creation, the screen immediately refreshes to show the new list.

  • Save app/(tabs)/index.tsx.
  • On your phone, tap the **Library** tab. You should see the empty state with a library icon and "No lists yet" text, plus a red (+) button in the bottom-right corner.
  • Tap the (+) button, type a list name like Peak Fiction, and tap Create. The list should appear as a card with a placeholder icon.
  • Switch to the Search tab, find a movie, and add it to your list. Switch back to Library. The list card should now show that movie's poster as its cover.

Library tab still showing the default template?

Make sure you replaced the entire contents of app/(tabs)/index.tsx and saved the file. The dev server should auto-reload.

If the (+) button doesn't appear, check that your styles include the fab style with position: 'absolute'. Without absolute positioning, it won't float over the list.

Help me debug my Library screen

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

import { View, Text, FlatList, TouchableOpacity, StyleSheet, Image, TextInput, Modal } from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router';
import { useState, useCallback } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { loadLists, createList } from '../../lib/storage';
import { MediaList } from '../../lib/types';

export default function LibraryScreen() {
  const router = useRouter();
  const [lists, setLists] = useState<MediaList[]>([]);
  const [showModal, setShowModal] = useState(false);
  const [newListName, setNewListName] = useState('');

  useFocusEffect(
    useCallback(() => {
      loadLists().then(setLists);
    }, [])
  );

  const handleCreateList = async () => {
    if (newListName.trim()) {
      await createList(newListName.trim());
      setNewListName('');
      setShowModal(false);
      const updated = await loadLists();
      setLists(updated);
    }
  };

  return (
    <View style={styles.container}>
      <FlatList
        data={lists}
        keyExtractor={(item) => item.id}
        numColumns={2}
        columnWrapperStyle={styles.row}
        contentContainerStyle={styles.listContent}
        ListEmptyComponent={
          <View style={styles.emptyState}>
            <Ionicons name="library-outline" size={64} color="#555" />
            <Text style={styles.emptyText}>No lists yet</Text>
            <Text style={styles.emptySubtext}>Create a list and start adding media!</Text>
          </View>
        }
        renderItem={({ item }) => (
          <TouchableOpacity
            style={styles.listCard}
            onPress={() => router.push(`/list/${item.id}`)}
          >
            {item.items.length > 0 && item.items[0].posterUrl ? (
              <Image source={{ uri: item.items[0].posterUrl }} style={styles.listCover} />
            ) : (
              <View style={[styles.listCover, styles.placeholderCover]}>
                <Ionicons name="images-outline" size={32} color="#555" />
              </View>
            )}
            <View style={styles.listInfo}>
              <Text style={styles.listName} numberOfLines={1}>{item.name}</Text>
              <Text style={styles.listCount}>{item.items.length} items</Text>
            </View>
          </TouchableOpacity>
        )}
      />
      <TouchableOpacity style={styles.fab} onPress={() => setShowModal(true)}>
        <Ionicons name="add" size={28} color="#fff" />
      </TouchableOpacity>

      <Modal visible={showModal} transparent animationType="fade">
        <View style={styles.modalOverlay}>
          <View style={styles.modalContent}>
            <Text style={styles.modalTitle}>New List</Text>
            <TextInput
              style={styles.modalInput}
              placeholder="List name..."
              placeholderTextColor="#666"
              value={newListName}
              onChangeText={setNewListName}
              autoFocus
            />
            <View style={styles.modalButtons}>
              <TouchableOpacity onPress={() => setShowModal(false)} style={styles.modalCancel}>
                <Text style={styles.modalCancelText}>Cancel</Text>
              </TouchableOpacity>
              <TouchableOpacity onPress={handleCreateList} style={styles.modalCreate}>
                <Text style={styles.modalCreateText}>Create</Text>
              </TouchableOpacity>
            </View>
          </View>
        </View>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a' },
  listContent: { padding: 16, paddingBottom: 80 },
  row: { justifyContent: 'space-between' },
  listCard: { width: '48%', marginBottom: 16, borderRadius: 12, overflow: 'hidden', backgroundColor: '#1a1a2e' },
  listCover: { width: '100%', height: 200, resizeMode: 'cover' },
  placeholderCover: { justifyContent: 'center', alignItems: 'center', backgroundColor: '#2a2a4e' },
  listInfo: { padding: 10 },
  listName: { color: '#fff', fontSize: 14, fontWeight: '600' },
  listCount: { color: '#8888aa', fontSize: 12, marginTop: 2 },
  emptyState: { alignItems: 'center', marginTop: 100 },
  emptyText: { color: '#fff', fontSize: 20, fontWeight: '600', marginTop: 16 },
  emptySubtext: { color: '#8888aa', fontSize: 14, marginTop: 8 },
  fab: { position: 'absolute', bottom: 24, right: 24, width: 56, height: 56, borderRadius: 28, backgroundColor: '#e94560', justifyContent: 'center', alignItems: 'center', elevation: 5 },
  modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', alignItems: 'center' },
  modalContent: { backgroundColor: '#1a1a2e', borderRadius: 16, padding: 24, width: '80%' },
  modalTitle: { color: '#fff', fontSize: 18, fontWeight: '600', marginBottom: 16 },
  modalInput: { backgroundColor: '#0f0f1a', color: '#fff', borderRadius: 8, padding: 12, fontSize: 16, marginBottom: 16 },
  modalButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: 12 },
  modalCancel: { padding: 10 },
  modalCancelText: { color: '#8888aa', fontSize: 14 },
  modalCreate: { backgroundColor: '#e94560', paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8 },
  modalCreateText: { color: '#fff', fontSize: 14, fontWeight: '600' },
});
Build the list detail view

When a user taps a list card, the app navigates to a detail screen showing every media item in that list. This screen also lets users remove items with a long-press gesture.

  • Create a new file at app/list/[id].tsx and paste the following code:
import { View, Text, FlatList, StyleSheet, Image, TouchableOpacity, Alert } from 'react-native';
import { useLocalSearchParams, useFocusEffect } from 'expo-router';
import { useState, useCallback } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { loadLists, removeMediaFromList } from '../../lib/storage';
import { MediaList } from '../../lib/types';

export default function ListDetailScreen() {
  // Get the list ID from the URL parameter
  const { id } = useLocalSearchParams<{ id: string }>();
  const [list, setList] = useState<MediaList | null>(null);

  // Reload list data every time this screen comes into focus
  useFocusEffect(
    useCallback(() => {
      loadLists().then((lists) => {
        const found = lists.find((l) => l.id === id);
        setList(found || null);
      });
    }, [id])
  );

  // Show confirmation dialog before removing an item
  const handleRemove = (itemId: string, title: string) => {
    Alert.alert('Remove Item', `Remove "${title}" from this list?`, [
      { text: 'Cancel', style: 'cancel' },
      {
        text: 'Remove',
        style: 'destructive',
        onPress: async () => {
          if (id) {
            await removeMediaFromList(id, itemId);
            const updated = await loadLists();
            setList(updated.find((l) => l.id === id) || null);
          }
        },
      },
    ]);
  };

  if (!list) {
    return (
      <View style={styles.emptyContainer}>
        <Text style={styles.emptyText}>List not found</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>{list.name}</Text>
      <Text style={styles.count}>{list.items.length} items</Text>
      {list.items.length === 0 ? (
        <View style={styles.emptyItems}>
          <Ionicons name="images-outline" size={48} color="#555" />
          <Text style={styles.emptyItemsText}>No items yet. Go search and add some!</Text>
        </View>
      ) : (
        <FlatList
          data={list.items}
          keyExtractor={(item) => item.id}
          numColumns={2}
          columnWrapperStyle={styles.row}
          contentContainerStyle={styles.listContent}
          renderItem={({ item }) => (
            <TouchableOpacity style={styles.card} onLongPress={() => handleRemove(item.id, item.title)}>
              {item.posterUrl ? (
                <Image source={{ uri: item.posterUrl }} style={styles.poster} />
              ) : (
                <View style={[styles.poster, styles.noPoster]}>
                  <Ionicons name="image-outline" size={24} color="#555" />
                </View>
              )}
              <View style={styles.cardInfo}>
                <Text style={styles.cardTitle} numberOfLines={2}>{item.title}</Text>
                <Text style={styles.cardCategory}>{item.category}</Text>
              </View>
            </TouchableOpacity>
          )}
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a', padding: 16 },
  emptyContainer: { flex: 1, backgroundColor: '#0f0f1a', justifyContent: 'center', alignItems: 'center' },
  title: { color: '#fff', fontSize: 24, fontWeight: '700' },
  count: { color: '#8888aa', fontSize: 13, marginTop: 4, marginBottom: 16 },
  emptyText: { color: '#8888aa', fontSize: 16 },
  emptyItems: { alignItems: 'center', marginTop: 60, gap: 12 },
  emptyItemsText: { color: '#8888aa', fontSize: 14, textAlign: 'center' },
  row: { justifyContent: 'space-between' },
  listContent: { paddingBottom: 20 },
  card: { width: '48%', marginBottom: 16, borderRadius: 12, overflow: 'hidden', backgroundColor: '#1a1a2e' },
  poster: { width: '100%', height: 200, resizeMode: 'cover' },
  noPoster: { justifyContent: 'center', alignItems: 'center', backgroundColor: '#2a2a4e' },
  cardInfo: { padding: 8 },
  cardTitle: { color: '#fff', fontSize: 13, fontWeight: '500' },
  cardCategory: { color: '#8888aa', fontSize: 11, marginTop: 2, textTransform: 'capitalize' },
});

What does this code do?

useLocalSearchParams extracts the id from the URL. When the Library screen calls router.push(`/list/${item.id}`), Expo Router matches the [id] dynamic segment and passes it here.

The onLongPress handler triggers a native Alert.alert confirmation dialog. If the user confirms, the item is removed from storage and the screen refreshes.

Why useFocusEffect instead of useEffect?

In tab-based navigation, screens stay mounted in memory even when you switch away from them. A regular useEffect only runs when the component first mounts, so it would miss any changes made on other screens.

useFocusEffect re-runs every time the screen becomes visible. This means your Library always shows fresh data after you add items from the Search tab.

  • Save app/list/[id].tsx.
  • On your phone, go to the Library tab and tap a list card that has items in it.
  • You should see a detail screen with the list name, item count, and a 2-column grid of poster cards showing each media item's title and category.
  • Long-press any item. A confirmation alert should appear asking if you want to remove it. Tap Remove and the item should disappear from the grid.

Tapping a list card does nothing?

Make sure the file is at the exact path app/list/[id].tsx with square brackets in the filename. The brackets tell Expo Router this is a dynamic route.

Check that app/_layout.tsx still has the list/[id] Stack.Screen entry. If you accidentally removed it, the router won't know how to handle navigation to that path.

Help me debug my list detail navigation

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

import { View, Text, FlatList, StyleSheet, Image, TouchableOpacity, Alert } from 'react-native';
import { useLocalSearchParams, useFocusEffect } from 'expo-router';
import { useState, useCallback } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { loadLists, removeMediaFromList } from '../../lib/storage';
import { MediaList } from '../../lib/types';

export default function ListDetailScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const [list, setList] = useState<MediaList | null>(null);

  useFocusEffect(
    useCallback(() => {
      loadLists().then((lists) => {
        const found = lists.find((l) => l.id === id);
        setList(found || null);
      });
    }, [id])
  );

  const handleRemove = (itemId: string, title: string) => {
    Alert.alert('Remove Item', `Remove "${title}" from this list?`, [
      { text: 'Cancel', style: 'cancel' },
      {
        text: 'Remove',
        style: 'destructive',
        onPress: async () => {
          if (id) {
            await removeMediaFromList(id, itemId);
            const updated = await loadLists();
            setList(updated.find((l) => l.id === id) || null);
          }
        },
      },
    ]);
  };

  if (!list) {
    return (
      <View style={styles.emptyContainer}>
        <Text style={styles.emptyText}>List not found</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>{list.name}</Text>
      <Text style={styles.count}>{list.items.length} items</Text>
      {list.items.length === 0 ? (
        <View style={styles.emptyItems}>
          <Ionicons name="images-outline" size={48} color="#555" />
          <Text style={styles.emptyItemsText}>No items yet. Go search and add some!</Text>
        </View>
      ) : (
        <FlatList
          data={list.items}
          keyExtractor={(item) => item.id}
          numColumns={2}
          columnWrapperStyle={styles.row}
          contentContainerStyle={styles.listContent}
          renderItem={({ item }) => (
            <TouchableOpacity style={styles.card} onLongPress={() => handleRemove(item.id, item.title)}>
              {item.posterUrl ? (
                <Image source={{ uri: item.posterUrl }} style={styles.poster} />
              ) : (
                <View style={[styles.poster, styles.noPoster]}>
                  <Ionicons name="image-outline" size={24} color="#555" />
                </View>
              )}
              <View style={styles.cardInfo}>
                <Text style={styles.cardTitle} numberOfLines={2}>{item.title}</Text>
                <Text style={styles.cardCategory}>{item.category}</Text>
              </View>
            </TouchableOpacity>
          )}
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a', padding: 16 },
  emptyContainer: { flex: 1, backgroundColor: '#0f0f1a', justifyContent: 'center', alignItems: 'center' },
  title: { color: '#fff', fontSize: 24, fontWeight: '700' },
  count: { color: '#8888aa', fontSize: 13, marginTop: 4, marginBottom: 16 },
  emptyText: { color: '#8888aa', fontSize: 16 },
  emptyItems: { alignItems: 'center', marginTop: 60, gap: 12 },
  emptyItemsText: { color: '#8888aa', fontSize: 14, textAlign: 'center' },
  row: { justifyContent: 'space-between' },
  listContent: { paddingBottom: 20 },
  card: { width: '48%', marginBottom: 16, borderRadius: 12, overflow: 'hidden', backgroundColor: '#1a1a2e' },
  poster: { width: '100%', height: 200, resizeMode: 'cover' },
  noPoster: { justifyContent: 'center', alignItems: 'center', backgroundColor: '#2a2a4e' },
  cardInfo: { padding: 8 },
  cardTitle: { color: '#fff', fontSize: 13, fontWeight: '500' },
  cardCategory: { color: '#8888aa', fontSize: 11, marginTop: 2, textTransform: 'capitalize' },
});

Your Library is fully functional. Users can create lists, see them with beautiful poster covers, tap into details, and remove items they no longer want. Next up, you'll build the AI roast feature that analyzes a user's taste and delivers a personalized burn.

Build the AI Roast Feature

Your app can search media, build custom lists, and display everything with poster art. Now it is time to add the feature that makes this app unique.

In this step, you will build the Roast Me screen where users pick a list and get a savage, AI-generated critique of their taste. The Google Gemini API powers the roasts, and it is completely free to use.

In this step, get ready to:
  • Build the roast screen UI with a list picker and roast button.
  • Integrate Google Gemini to generate personalized roasts from your media lists.
Build the roast screen with list picker

The roast screen lets users choose which list to analyze, then fires off the request. It needs chips to pick a list, a big button to trigger the roast, and a styled card to display the result.

  • Open app/(tabs)/roast.tsx in your editor. Replace any existing content with the following component code:
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
import { useState, useCallback } from 'react';
import { useFocusEffect } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { loadLists } from '../../lib/storage';
import { generateRoast } from '../../lib/api';
import { MediaList } from '../../lib/types';

export default function RoastScreen() {
  const [lists, setLists] = useState<MediaList[]>([]);
  const [selectedListId, setSelectedListId] = useState<string>('all');
  const [roast, setRoast] = useState<string>('');
  const [loading, setLoading] = useState(false);

  // Reload lists every time the tab comes into focus
  useFocusEffect(
    useCallback(() => {
      loadLists().then(setLists);
    }, [])
  );

  const handleRoast = async () => {
    setLoading(true);
    setRoast('');
    let mediaNames: string[] = [];

    // Gather media titles based on selected list
    if (selectedListId === 'all') {
      mediaNames = lists.flatMap((l) => l.items.map((i) => `${i.title} (${i.category})`));
    } else {
      const list = lists.find((l) => l.id === selectedListId);
      if (list) {
        mediaNames = list.items.map((i) => `${i.title} (${i.category})`);
      }
    }

    // Handle empty state
    if (mediaNames.length === 0) {
      setRoast("You haven't added anything yet. Your taste is so bland you can't even commit to liking something. Add some media first!");
      setLoading(false);
      return;
    }

    // Call the AI and display result
    const listName = selectedListId === 'all' ? 'entire collection' : lists.find((l) => l.id === selectedListId)?.name || 'list';
    const result = await generateRoast(mediaNames, listName);
    setRoast(result);
    setLoading(false);
  };

  return (
    <ScrollView style={styles.container} contentContainerStyle={styles.content}>
      <Text style={styles.title}>Get Roasted</Text>
      <Text style={styles.subtitle}>Pick a list and prepare to be destroyed</Text>

      <View style={styles.pickerContainer}>
        <TouchableOpacity
          style={[styles.pickerOption, selectedListId === 'all' && styles.pickerActive]}
          onPress={() => setSelectedListId('all')}
        >
          <Text style={[styles.pickerText, selectedListId === 'all' && styles.pickerTextActive]}>All Media</Text>
        </TouchableOpacity>
        {lists.map((list) => (
          <TouchableOpacity
            key={list.id}
            style={[styles.pickerOption, selectedListId === list.id && styles.pickerActive]}
            onPress={() => setSelectedListId(list.id)}
          >
            <Text style={[styles.pickerText, selectedListId === list.id && styles.pickerTextActive]}>
              {list.name}
            </Text>
          </TouchableOpacity>
        ))}
      </View>

      <TouchableOpacity style={styles.roastButton} onPress={handleRoast} disabled={loading}>
        {loading ? (
          <ActivityIndicator color="#fff" />
        ) : (
          <>
            <Ionicons name="flame" size={22} color="#fff" />
            <Text style={styles.roastButtonText}>Roast Me</Text>
          </>
        )}
      </TouchableOpacity>

      {loading && <Text style={styles.loadingText}>Analyzing your terrible taste...</Text>}

      {roast !== '' && !loading && (
        <View style={styles.roastCard}>
          <Ionicons name="flame" size={28} color="#e94560" />
          <Text style={styles.roastText}>{roast}</Text>
        </View>
      )}
    </ScrollView>
  );
}

What does this code do?

  • useFocusEffect reloads the user's lists every time they switch to this tab. This ensures any lists created on the Library or Search screens appear immediately.
  • The picker chips render an "All Media" option plus one chip per user-created list. Tapping a chip sets selectedListId which controls which list gets roasted.
  • handleRoast gathers all media titles from the selected list, handles the empty state with a snarky fallback message, and calls generateRoast to get the AI response.
  • The loading state shows an ActivityIndicator inside the button and a "terrible taste" message below it.
  • Now add the styles below the component in the same file:
const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a' },
  content: { padding: 24 },
  title: { color: '#fff', fontSize: 28, fontWeight: '700' },
  subtitle: { color: '#8888aa', fontSize: 14, marginTop: 4, marginBottom: 24 },
  pickerContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 24 },
  pickerOption: { paddingHorizontal: 16, paddingVertical: 10, borderRadius: 20, backgroundColor: '#1a1a2e' },
  pickerActive: { backgroundColor: '#e94560' },
  pickerText: { color: '#8888aa', fontSize: 13, fontWeight: '500' },
  pickerTextActive: { color: '#fff' },
  roastButton: { flexDirection: 'row', gap: 8, backgroundColor: '#e94560', borderRadius: 16, padding: 18, alignItems: 'center', justifyContent: 'center', marginBottom: 16 },
  roastButtonText: { color: '#fff', fontSize: 18, fontWeight: '700' },
  loadingText: { color: '#8888aa', textAlign: 'center', marginTop: 8 },
  roastCard: { backgroundColor: '#1a1a2e', borderRadius: 16, padding: 24, marginTop: 16, borderLeftWidth: 4, borderLeftColor: '#e94560', gap: 12 },
  roastText: { color: '#fff', fontSize: 16, lineHeight: 24 },
});

What do the styles do?

The picker chips use flexWrap: 'wrap' so they flow onto new lines if the user has many lists. The accent color #e94560 highlights the active chip and the roast button.

The roast card uses borderLeftWidth: 4 with the accent color to create a bold left border that makes the AI response feel like a pulled quote.

  • Save app/(tabs)/roast.tsx.
  • On your phone, tap the Roast Me tab. You should see a "Get Roasted" heading, an "All Media" chip, and a large red Roast Me button.

Don't see the Roast Me tab?

  • Make sure you saved the file. Check that the filename is exactly roast.tsx inside app/(tabs)/.
  • If you see a blank screen or error, check for typos in the import paths. The storage and api imports use ../../lib/ because the file is two folders deep.
  • Shake your phone to open the Expo developer menu and tap Reload to force a full refresh.

Help me debug my Roast Me tab.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/(tabs)/roast.tsx before moving on.

ⓧ I'd like to double check the full code

import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
import { useState, useCallback } from 'react';
import { useFocusEffect } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { loadLists } from '../../lib/storage';
import { generateRoast } from '../../lib/api';
import { MediaList } from '../../lib/types';

export default function RoastScreen() {
  const [lists, setLists] = useState<MediaList[]>([]);
  const [selectedListId, setSelectedListId] = useState<string>('all');
  const [roast, setRoast] = useState<string>('');
  const [loading, setLoading] = useState(false);

  useFocusEffect(
    useCallback(() => {
      loadLists().then(setLists);
    }, [])
  );

  const handleRoast = async () => {
    setLoading(true);
    setRoast('');
    let mediaNames: string[] = [];

    if (selectedListId === 'all') {
      mediaNames = lists.flatMap((l) => l.items.map((i) => `${i.title} (${i.category})`));
    } else {
      const list = lists.find((l) => l.id === selectedListId);
      if (list) {
        mediaNames = list.items.map((i) => `${i.title} (${i.category})`);
      }
    }

    if (mediaNames.length === 0) {
      setRoast("You haven't added anything yet. Your taste is so bland you can't even commit to liking something. Add some media first!");
      setLoading(false);
      return;
    }

    const listName = selectedListId === 'all' ? 'entire collection' : lists.find((l) => l.id === selectedListId)?.name || 'list';
    const result = await generateRoast(mediaNames, listName);
    setRoast(result);
    setLoading(false);
  };

  return (
    <ScrollView style={styles.container} contentContainerStyle={styles.content}>
      <Text style={styles.title}>Get Roasted</Text>
      <Text style={styles.subtitle}>Pick a list and prepare to be destroyed</Text>

      <View style={styles.pickerContainer}>
        <TouchableOpacity
          style={[styles.pickerOption, selectedListId === 'all' && styles.pickerActive]}
          onPress={() => setSelectedListId('all')}
        >
          <Text style={[styles.pickerText, selectedListId === 'all' && styles.pickerTextActive]}>All Media</Text>
        </TouchableOpacity>
        {lists.map((list) => (
          <TouchableOpacity
            key={list.id}
            style={[styles.pickerOption, selectedListId === list.id && styles.pickerActive]}
            onPress={() => setSelectedListId(list.id)}
          >
            <Text style={[styles.pickerText, selectedListId === list.id && styles.pickerTextActive]}>
              {list.name}
            </Text>
          </TouchableOpacity>
        ))}
      </View>

      <TouchableOpacity style={styles.roastButton} onPress={handleRoast} disabled={loading}>
        {loading ? (
          <ActivityIndicator color="#fff" />
        ) : (
          <>
            <Ionicons name="flame" size={22} color="#fff" />
            <Text style={styles.roastButtonText}>Roast Me</Text>
          </>
        )}
      </TouchableOpacity>

      {loading && <Text style={styles.loadingText}>Analyzing your terrible taste...</Text>}

      {roast !== '' && !loading && (
        <View style={styles.roastCard}>
          <Ionicons name="flame" size={28} color="#e94560" />
          <Text style={styles.roastText}>{roast}</Text>
        </View>
      )}
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f1a' },
  content: { padding: 24 },
  title: { color: '#fff', fontSize: 28, fontWeight: '700' },
  subtitle: { color: '#8888aa', fontSize: 14, marginTop: 4, marginBottom: 24 },
  pickerContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 24 },
  pickerOption: { paddingHorizontal: 16, paddingVertical: 10, borderRadius: 20, backgroundColor: '#1a1a2e' },
  pickerActive: { backgroundColor: '#e94560' },
  pickerText: { color: '#8888aa', fontSize: 13, fontWeight: '500' },
  pickerTextActive: { color: '#fff' },
  roastButton: { flexDirection: 'row', gap: 8, backgroundColor: '#e94560', borderRadius: 16, padding: 18, alignItems: 'center', justifyContent: 'center', marginBottom: 16 },
  roastButtonText: { color: '#fff', fontSize: 18, fontWeight: '700' },
  loadingText: { color: '#8888aa', textAlign: 'center', marginTop: 8 },
  roastCard: { backgroundColor: '#1a1a2e', borderRadius: 16, padding: 24, marginTop: 16, borderLeftWidth: 4, borderLeftColor: '#e94560', gap: 12 },
  roastText: { color: '#fff', fontSize: 16, lineHeight: 24 },
});
Integrate Google Gemini API

The roast screen is wired up but the generateRoast function in lib/api.ts still returns an empty string. You will now connect it to the Gemini API so tapping Roast Me sends the user's media list to the AI and displays the response.

  • In lib/api.ts, scroll to the bottom of the file and find the placeholder generateRoast function:
export async function generateRoast(mediaNames: string[], listName: string): Promise<string> {
  return '';
}
  • Replace the entire generateRoast function with this implementation:
export async function generateRoast(mediaNames: string[], listName: string): Promise<string> {
  // Build the Gemini API URL with your key
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`;

  // Craft a prompt that tells the AI to be a savage critic
  const prompt = `You are a brutally honest and hilarious media critic. Roast this person's taste based on their "${listName}" collection. Be funny, savage, and specific about their choices. Keep it under 150 words. Here's what they watch/listen to:\n\n${mediaNames.join('\n')}`;

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
      }),
    });

    const data = await response.json();
    return data.candidates?.[0]?.content?.parts?.[0]?.text || "Couldn't generate a roast. Your taste broke the AI.";
  } catch {
    return "Something went wrong. Even the internet doesn't want to deal with your taste.";
  }
}

What does this code do?

  • The function sends a POST request to the Gemini generateContent endpoint. The API key is appended as a query parameter.
  • The prompt tells the AI to act as a "brutally honest media critic" and constrains the response to 150 words. It includes the list name and all media titles so the roast can reference specific choices.
  • The response JSON is nested: data.candidates[0].content.parts[0].text extracts the generated text. Optional chaining (?.) prevents crashes if the response shape is unexpected.
  • Both the empty-response case and the network-error catch return humorous fallback messages so the app never shows a blank screen or crashes.
  • Save lib/api.ts.
  • On your phone, go to the Search tab and add at least 3 items to any list.
  • Switch to the Roast Me tab, select that list, and tap the Roast Me button.
  • You should see "Analyzing your terrible taste..." briefly, then a styled card with a flame icon and your personalized roast.

Not getting a roast?

  • If you see the fallback message "Something went wrong", double check that your Gemini API key in lib/api.ts is correct. Make sure you replaced your-gemini-api-key-here with your actual key from Google AI Studio.
  • If you see "Couldn't generate a roast", the API responded but the response format was empty. Verify your Gemini key has access to gemini-2.5-flash by testing it in the Google AI Studio playground.
  • Make sure you have at least 3 items in the list you selected. A list with zero items triggers the empty state message instead of calling the API.

Help me debug my Gemini API integration for the roast feature.

✔️ Awesome, I've got everything!

Great. Make sure you have saved lib/api.ts before moving on.

ⓧ I'd like to double check the full code

import { MediaItem, MediaCategory } from './types';

const TMDB_API_KEY = 'your-tmdb-api-key-here';
const GEMINI_API_KEY = 'your-gemini-api-key-here';

export async function searchMedia(query: string, category: MediaCategory): Promise<MediaItem[]> {
  if (category === 'music') {
    return searchMusic(query);
  }
  return searchTMDB(query, category);
}

async function searchTMDB(query: string, category: MediaCategory): Promise<MediaItem[]> {
  const type = category === 'movie' ? 'movie' : 'tv';
  const url = `https://api.themoviedb.org/3/search/${type}?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(query)}&page=1`;

  try {
    const response = await fetch(url);
    const data = await response.json();

    let results = data.results || [];

    if (category === 'anime') {
      results = results.filter((item: any) => item.genre_ids?.includes(16));
    }

    return results.slice(0, 10).map((item: any) => ({
      id: `tmdb-${item.id}`,
      title: item.title || item.name || 'Unknown',
      category,
      posterUrl: item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : null,
      year: (item.release_date || item.first_air_date || '').split('-')[0] || '',
    }));
  } catch {
    return [];
  }
}

async function searchMusic(query: string): Promise<MediaItem[]> {
  const url = `https://itunes.apple.com/search?term=${encodeURIComponent(query)}&media=music&entity=album&limit=10`;

  try {
    const response = await fetch(url);
    const data = await response.json();

    return (data.results || []).map((item: any) => ({
      id: `itunes-${item.collectionId}`,
      title: `${item.collectionName} - ${item.artistName}`,
      category: 'music' as MediaCategory,
      posterUrl: item.artworkUrl100 ? item.artworkUrl100.replace('100x100', '600x600') : null,
      year: item.releaseDate ? new Date(item.releaseDate).getFullYear().toString() : '',
    }));
  } catch {
    return [];
  }
}

export async function generateRoast(mediaNames: string[], listName: string): Promise<string> {
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`;

  const prompt = `You are a brutally honest and hilarious media critic. Roast this person's taste based on their "${listName}" collection. Be funny, savage, and specific about their choices. Keep it under 150 words. Here's what they watch/listen to:\n\n${mediaNames.join('\n')}`;

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
      }),
    });

    const data = await response.json();
    return data.candidates?.[0]?.content?.parts?.[0]?.text || "Couldn't generate a roast. Your taste broke the AI.";
  } catch {
    return "Something went wrong. Even the internet doesn't want to deal with your taste.";
  }
}

Your app is fully functional. You can search for any movie, TV show, anime, or album, save it to custom lists, and get a personalized AI roast of your taste. That is a complete, working React Native app running on your phone.

Secret mission

Add Roast History

Your roasts disappear the moment you leave the screen. What if you could save every burn and revisit your best (worst?) moments later? In this secret mission, you'll build a persistent roast history with expandable cards so no savage takedown is ever lost.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your phone and your local machine. Both API keys you used (TMDB and Gemini) are on free tiers with no billing attached, so there are no ongoing costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • TMDB API key (free, no billing attached).
  • Google Gemini API key (free tier, no credit card required).
  • RoastMyTaste project folder on your Desktop.
  • Expo Go app installed on your Android phone.
  • Local app data stored on your phone via expo-sqlite.

Keep everything running

No action needed. Choose this if you want to keep building on the app or run it again later.

  • Everything is free and local. There are no recurring charges, no cloud resources spinning, and no subscriptions to cancel.
  • Your TMDB and Gemini API keys will continue to work on the free tier indefinitely.
  • Your project files remain in the RoastMyTaste folder on your Desktop.

Pause - I'll come back to this later

Stop the running dev server to free up your terminal, but keep all files and keys intact for later.

  • In the terminal where the Expo dev server is running, press Ctrl+C to stop it.
  • Your project files, API keys, and phone app data all remain untouched.
  • When you want to resume, navigate back to your RoastMyTaste folder and run npx expo start again.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to clean up completely.

  • Stop the Expo dev server by pressing Ctrl+C in the terminal.
  • Delete your project folder by running these commands:
cd ~/Desktop
rm -rf RoastMyTaste

On Windows, run this instead:

cd ~\Desktop
Remove-Item -Recurse -Force RoastMyTaste
  • Uninstall Expo Go from your Android phone if you no longer need it. Open Settings on your phone, go to Apps, find Expo Go, and tap Uninstall.
  • Delete your Gemini API key. Open the Google Cloud API Credentials page at console.cloud.google.com/apis/credentials. Find the API key you created, click the Actions icon (three dots), select Delete API key, and confirm in the modal.
  • Your TMDB API key is free with no billing. If you want to fully remove it, log in to themoviedb.org, go to your account settings, and click Delete Account to remove your account and associated key. TMDB does not offer a self-service key revocation option without deleting the account.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete mobile app from scratch that searches media across multiple APIs, organizes it into custom lists, and roasts your taste with AI.

You've learned how to:

  • Built a multi-screen React Native app with Expo Router file-based tab navigation, TypeScript, and a polished dark theme running on a physical Android device.
  • Integrated two external APIs (TMDB for movies, TV, and anime plus iTunes Search for music) to fetch and display image-rich results in a FlatList grid.
  • Implemented local persistence with expo-sqlite/kv-store for custom list management and connected Google Gemini AI to generate personalized roasts based on user data.
  • Secret Mission: Added persistent roast history with expandable card UI so users can revisit their best burns.

Ready to quiz yourself?