Mastering API Integration in Jetpack Compose: A Comprehensive Guide Using MoviesDB API

Jetpack Compose has revolutionized how we build UIs in Android apps, but beautiful UIs mean little if your app can’t communicate with the outside world. That’s where API integration comes in.

In this hands-on guide, we’ll go beyond static previews and learn how to fetch and display real-time movie data using the MoviesDB API. You’ll learn how to set up Retrofit, Moshi, and Hilt the right way, build a clean architecture around them, and present data through sleek, modern Compose UIs.

Whether you’re building a client project, a startup MVP, or your next side hustle, this article is your practical blueprint to integrating APIs in Jetpack Compose like a pro.

Basics of API Integration in Jetpack Compose

APIs are the backbone of modern mobile applications, enabling them to fetch data from remote servers. When it comes to Jetpack Compose, integrating APIs involves setting up network calls, managing state, and displaying the fetched data in a composable UI. Let’s dive into the steps required to achieve this.

Setting Up Your Project

Before diving into API integration, ensure that your development environment is ready.

Before we start coding, let’s ensure that your development environment is ready. Make sure you have the latest version of Android Studio and Kotlin installed.

Step 1: Create a New Jetpack Compose Project (Official guide)

  1. Open Android Studio and select “New Project”.

  2. Choose “Empty Compose Activity” from the project templates.

  3. Name your project and set the minimum SDK to 21 (or higher).

  4. Click “Finish” to create your project.

Step 2: Add Required Dependencies

To make network calls, we’ll use the following libraries:

  • Retrofit: For making HTTP requests.

  • Moshi: For JSON parsing.

  • Hilt: For dependency injection.

Add the following dependencies to your build.gradle file:

// In your project-level build.gradle
plugins {
    ...
    id("com.google.dagger.hilt.android") version "2.48" apply false
}

// In your app-level build.gradle
plugins {
    ...
    id 'kotlin-kapt'
    id 'com.google.dagger.hilt.android'
}

android {
    ...
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
}

dependencies {
    ...
    ...
    implementation("androidx.compose.ui:ui:1.6.8")
    implementation("androidx.compose.material:material:1.6.8")
    implementation("androidx.compose.ui:ui-tooling-preview:1.6.8")
    implementation("androidx.activity:activity-compose:1.7.2")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")

    // Networking
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
    implementation("com.squareup.moshi:moshi-kotlin:1.13.0")
    implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2")

    // Dependency Injection
    implementation("com.google.dagger:hilt-android:2.48")
    kapt("com.google.dagger:hilt-compiler:2.48")
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")

    // Navigation
    implementation("androidx.navigation:navigation-compose:2.7.7")

    // Image loading
    implementation("io.coil-kt:coil-compose:2.7.0")
}

kapt {
    correctErrorTypes = true
}

Make sure to sync your project after adding these dependencies.
That’s it. Now we have enabled Compose in our Android project. Now we are ready to move forward.

Structuring Your Jetpack Compose Project

Before diving into API integration, it’s essential to establish a well-organized folder structure. Here’s a recommended structure for your project:

  • data/api: Contains classes for API service interfaces.

  • data/model: Contains data models representing API responses.

  • data/repository: Contains repository classes to handle data operations.

  • di: Contains dependency injection-related classes.

  • ui/theme: Contains theme-related classes like colors, typography, etc.

  • ui/components: Contains reusable UI components.

  • ui/screens: Contains composable functions representing different screens.

  • viewmodel: Contains ViewModel classes for managing UI-related data.

Setting Up the MoviesDB API Integration

Step 1: Define the API Service

First, create an interface that defines the endpoints for the MoviesDB API. In the data/api folder, create a file named MoviesApiService.kt:

interface MoviesApiService {
    @GET("movie/popular")
    suspend fun getPopularMovies(
        @Header("Authorization") token: String,
        @Query("page") page: Int
    ): Response

    @GET("movie/{movie_id}")
    suspend fun getMovieDetails(
        @Header("Authorization") token: String,
        @Path("movie_id") movieId: Int
    ): Response
}

Step 2: Create Data Models

Next, create data models to represent the JSON structure of the API responses. In the data/model folder, create files Movie.kt and MoviesResponse.kt and add the below classes in it respectively.

data class Movie(
    val id: Int,
    val title: String,
    val overview: String,
    val poster_path: String
)

data class MoviesResponse(
    val results: List
)

Next, add a data model for the movie details response. In the data/model folder, create a file named MovieDetailsResponse.kt:

data class MovieDetailsResponse(
    val id: Int,
    val title: String,
    val overview: String,
    val poster_path: String,
    val release_date: String,
    val runtime: Int,
    val vote_average: Float
)

Step 3: Set Up Retrofit and Hilt

Now, let’s set up Retrofit to handle the API calls. In the di folder, create a file named NetworkModule.kt:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.themoviedb.org/3/")
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
    }

    @Provides
    @Singleton
    fun provideMoviesApiService(retrofit: Retrofit): MoviesApiService {
        return retrofit.create(MoviesApiService::class.java)
    }
}

Implementing the Repository Pattern

Repositories serve as an intermediary between the API and the ViewModel. In the data/repository folder, create a file named MoviesRepository.kt:

@Singleton
class MoviesRepository @Inject constructor(
    private val apiService: MoviesApiService
) {
    suspend fun getPopularMovies(token: String, page: Int): List {
        val response = apiService.getPopularMovies(token, page)
        return if (response.isSuccessful) {
            response.body()?.results ?: emptyList()
        } else {
            emptyList()
        }
    }

    suspend fun getMovieDetails(token: String, movieId: Int): MovieDetailsResponse? {
        val response = apiService.getMovieDetails(token, movieId)
        return if (response.isSuccessful) {
            response.body()
        } else {
            null
        }
    }
}

Creating the ViewModel

The ViewModel will manage the UI state and interact with the repository. In the viewmodel folder, create a file named MoviesViewModel.kt:

@HiltViewModel
class MoviesViewModel @Inject constructor(
    private val repository: MoviesRepository
) : ViewModel() {

    private val _movies = mutableStateOf<List>(emptyList())
    val movies: State<List> = _movies

    private val _movieDetails = mutableStateOf<MovieDetailsResponse?>(null)
    val movieDetails: State<MovieDetailsResponse?> = _movieDetails

    init {
        getMovies()
    }

    private fun getMovies() {
        viewModelScope.launch {
            try {
                val moviesList = repository.getPopularMovies("Bearer YOUR_ACCESS_TOKEN", 1)
                _movies.value = moviesList
            } catch (e: Exception) {
// Handle exceptions
            }
        }
    }

    fun getMovieDetails(movieId: Int) {
        viewModelScope.launch {
            try {
                val details = repository.getMovieDetails("Bearer YOUR_ACCESS_TOKEN", movieId)
                _movieDetails.value = details
            } catch (e: Exception) {
// Handle exceptions
            }
        }
    }
}

Building the UI with Jetpack Compose

Now, let’s create the UI to display the list of movies. In the ui/screens folder, create a file named MoviesScreen.kt:

@Composable
fun MoviesScreen(navController: NavController, viewModel: MoviesViewModel = hiltViewModel()) {
    val movies by viewModel.movies

    LazyColumn {
        items(movies) { movie ->
            MovieItem(movie = movie, onClick = {
                navController.navigate("movie_details/${movie.id}")
            })
        }
    }
}

@Composable
fun MovieItem(movie: Movie, onClick: () -> Unit) {
    Row(
        modifier = Modifier
            .padding(8.dp)
            .clickable { onClick() }
    ) {
        AsyncImage(
            model = "https://image.tmdb.org/t/p/w500${movie.poster_path}",
            contentDescription = null,
            modifier = Modifier.size(120.dp)
        )
        Spacer(modifier = Modifier.width(8.dp))
        Column {
            Text(text = movie.title, style = MaterialTheme.typography.h6)
            Text(text = movie.overview, maxLines = 3, overflow = TextOverflow.Ellipsis)
        }
    }
}

Now, create the UI to display the movie details. In the ui/screens folder, create a new file named MovieDetailsScreen.kt:

@Composable
fun MovieDetailsScreen(movieId: Int, viewModel: MoviesViewModel = hiltViewModel()) {
    val movieDetails by remember { viewModel.movieDetails }

    LaunchedEffect(movieId) {
        viewModel.getMovieDetails(movieId)
    }

    movieDetails?.let { details ->
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(text = details.title, style = MaterialTheme.typography.h4)
            Text(text = "Released: ${details.release_date}")
            Text(text = "Runtime: ${details.runtime} minutes")
            Text(text = "Rating: ${details.vote_average}/10")
            Spacer(modifier = Modifier.height(8.dp))
            AsyncImage(
                model = "https://image.tmdb.org/t/p/w500${details.poster_path}",
                contentDescription = null,
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(modifier = Modifier.height(16.dp))
            Text(text = details.overview)
        }
    }
}

Setting Up the Navigation Graph

Finally, create a navigation graph in your MainActivity.kt:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApp {
                MoviesNavGraph()
            }
        }
    }
}

@Composable
fun MoviesNavGraph(startDestination: String = "movies_list") {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = startDestination) {
        composable("movies_list") {
            MoviesScreen(navController = navController)
        }
        composable(
            "movie_details/{movieId}",
            arguments = listOf(navArgument("movieId") { type = NavType.IntType })
        ) { backStackEntry ->
            val movieId = backStackEntry.arguments?.getInt("movieId") ?: return@composable
            MovieDetailsScreen(movieId = movieId)
        }
    }
}

Setting Up Hilt for Dependency Injection

Now, create an Application class and annotate it with @HiltAndroidApp: in the root of the package.

@HiltAndroidApp
class MyApp : Application()

This step sets up Hilt in your project. Hilt will now be able to inject dependencies into your Android components.

Now, in your AndroidManifest.xml file, add network permission and MyApp name in the Application Tag.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools">

    ...
    ...
    <uses-permission android:name="android.permission.INTERNET"/>

    <application
      android:name=".MyApp"
      ...
    >
        ...
    </application>

</manifest>

Now our application is finally ready😃. You can run it on a real device or emulator to bring your app to life and experience the smooth, intuitive UI firsthand. This marks the beginning of your journey into building more complex and feature-rich applications with Jetpack Compose.

Wrapping Up

With both popular movie listings and detailed pages wired up through MoviesDB API, you’ve now built a full-fledged Jetpack Compose app that actually feels alive. The clean architecture, modern tools like Hilt, Retrofit, and Coil, and the declarative UI all come together to showcase how powerful Compose can be when used right.

As you continue to explore and experiment, remember that the world of Jetpack Compose is vast and constantly evolving. Whether you’re refining this app, building something new, or integrating more advanced features, the possibilities are endless.

But we’re not done yet! In the upcoming parts of this Compose series, we’ll dive into real-world enhancements like local caching, paginated loading, and animating transitions that delight users.

🚀 If you found this guide helpful, consider bookmarking it or dropping a reaction to support more in-depth tutorials.

🔔 Follow @themodularmindset to stay updated with the next articles in the Jetpack Compose Mastery series.

1
Subscribe to my newsletter

Read articles from Dhaval Asodariya directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dhaval Asodariya
Dhaval Asodariya

👨‍💻 Software Engineer | 💡 Kotlin Expert | 📱 Android Enthusiast Previously SDE-III at DhiWise, I’m a Kotlin-focused developer passionate about building scalable, modern software-primarily on Android, but also exploring AI 🤖 and backend technologies. I use this space to share practical insights, clean code practices, and thoughts on the future of tech 🚀