Movies Database

Fix movie list

Now we'll fix things so we can display the real movie list and add a "reset database" button to the UI.

Right now, the MainActivity contains

private val viewModel by viewModels<MovieViewModel>()

to create or access an existing view model instance. This doesn't pass in the repository instance that we now need. To do this, we need to create a factory that viewModels() can use to create the instance. (Alternatively we could use a dependency-injection framework to create things for us, but that's out of scope right now.)

That factory needs to obtain an instance of the MovieDatabaseRepository. So we'll start there by defining a factory there.

show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieDatabaseRepository.kt
// ...
class MovieDatabaseRepository(
    // ...
): MovieRepository {
    // ...
    override suspend fun resetDatabase() = dao.resetDatabase()

    companion object {
        fun create(context: Context) =
            MovieDatabaseRepository(createDao(context))
    }
}

A companion object is a singleton object that can be used by all MovieDatabaseRepository instances, or its parts being called via class-qualified functions such as MovieDatabaseRepository.create(). This create function uses the database builder that we exposed from the data layer to create a return a MovieDatabaseRepository instance.

Back in the view model, we create another companion object, but this one defines a ViewModelProvider.Factory that can be used by the viewModels() in MainActivity when it needs to create an instance of the MovieViewModel.

A little bit of cleanup... We will be using MovieDto instead of the Movie defined in app. So we delete the Movie class from app and modify the use of it in MovieListUi, MovieDisplayUi, and MovieDisplay.

show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
// ...

@OptIn(ExperimentalMaterial3Api::class) // for TopAppBar
@Composable
fun MovieListUi(
//  movies: List<Movie>,
    movies: List<MovieDto>,
    modifier: Modifier = Modifier,
//  onMovieClicked: (Movie) -> Unit,
    onMovieClicked: (MovieDto) -> Unit,
    onResetDatabase: () -> Unit,
) {
    // ...
}
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieDisplay.kt
// ...

@OptIn(ExperimentalMaterial3Api::class) // for TopAppBar
@Composable
fun MovieDisplayUi(
//  movie: Movie,
    movie: MovieDto,
    modifier: Modifier = Modifier,
) {
    // ...
}
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/Screens.kt
// ...
sealed interface Screen
data object MovieList: Screen
//data class MovieDisplay(val movie: Movie): Screen
data class MovieDisplay(val movie: MovieDto): Screen

To get the list of movies, we'll now need to collect a Flow in our UI. Collection is how to observe and get new values from a Flow.

Compose defines the collectAsState() to start a coroutine to collect from a Flow and convert it into Compose State so it can be observed as part of a Snapshot. The collection stops if the part of the UI tree that contains it is removed. For example, if we collect in function a() and the current composition no longer calls a(), the collection stops.

This is great for flow collection in general, but Android adds an extra concern - lifecycles. When you switch from an application to the home screen, Android may or may not tell the application to destroy itself. It's possible for coroutines to keep running, and, in the case of collecting for display on a UI, it's possible that a non-displayed UI might be updated, which could crash.

To get around this, we have collectAsStateWithLifecycle(), which stops the collection if the UI is not active.

For more details on collectAsState vs collectAsStateWithLifecycle(), see Consuming flows safely in Jetpack Compose.

To use collectAsStateWithLifecycle(), we need to add a new dependency to our app module.

To do this, add lifecycle-compose to the version catalog and as a dependency in app/build.gradle.kts.

show in full file gradle/libs.versions.toml
[versions]
// ...
javaVersion = "VERSION_11"

lifecycle-compose = "2.8.7"

[libraries]
lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-compose" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
// ...
[plugins]
// ...
show in full file app/build.gradle.kts
// ...
dependencies {
    implementation(project(":repository"))

    implementation(libs.lifecycle.compose)
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    // ...
}

Now we can add the collection code. Note that you'll need to also import androidx.compose.runtime.getValue in addition to collectAsStateWithLifecycle() so Kotlin can delegate the movies property to the collected state.

show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/Ui.kt
// ...

@Composable
fun Ui(
    // ...
) {
    // ...
    when (val screen = viewModel.currentScreen) {
        // ...
        }
        MovieList -> {
            val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(
                initialValue = emptyList()
            )

            MovieListUi(
//              movies = viewModel.movies,
                movies = movies,
                modifier = modifier,
//          ) { movie ->
                // ...
            )
        }
    }
}

To finish things up, let's add a reset button to our MovieList that calls a passed-in reset event.

show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
// ...

@OptIn(ExperimentalMaterial3Api::class) // for TopAppBar
@Composable
fun MovieListUi(
    // ...
//  onMovieClicked: (Movie) -> Unit,
    onMovieClicked: (MovieDto) -> Unit,
    onResetDatabase: () -> Unit,
) {
    Scaffold(
        topBar = {
            TopAppBar(
                // ...
                    Text(text = stringResource(R.string.movies))
                },
                actions = {
                    IconButton (onClick = onResetDatabase) {
                        Icon(
                            imageVector = Icons.Default.Refresh,
                            contentDescription = stringResource(R.string.reset_database)
                        )
                    }
                }
            )
        },
        // ...
    ) { innerPadding ->
        // ...
    }
}

Our resetDatabase() function in the DAO, repository and view model is defined as a suspend function, meaning it must be executed in a coroutine. To launch a coroutine, we need a coroutine scope, so we'll need to create that coroutine scope in Ui(). We call resetDatabase() via an event function that we pass to Ui().

show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/Ui.kt
// ...

@Composable
fun Ui(
    // ...
) {
    // ...
    }

    val scope = rememberCoroutineScope()

    when (val screen = viewModel.currentScreen) {
        // ...
        MovieList -> {
            // ...
            MovieListUi(
                // ...
                movies = movies,
                modifier = modifier,
//          ) { movie ->
                onResetDatabase = {
                    scope.launch (Dispatchers.IO) {
                        viewModel.resetDatabase()
                    }
                },
                onMovieClicked = { movie ->
                    viewModel.pushScreen(MovieDisplay(movie))
                }
            )
        }
    }
}

Note

I restructured the call to MovieListUI's constructor because it now has multiple event lambdas. If one lambda feels more important that the others (or the others have reasonable defaults), you can keep it at the end for caller to use the lambda-outside-parens style. If there's no obvious primary action, or you must specify multiple lambdas on every call, I recommend you keep all of the lambdas inside the parens with parameter names, and do not use a lambda outside the params.

(Note that any Composable that has a content parameter at the end should be called using trailing-lambda syntax)

Finally, remove the hardcoded data from the MovieViewModel.

When we first run the application, we'll see and empty movie list. There's no data in the database.

empty movie list

Pressing the reset button on the tool bar adds data to the database. Because we're using a Flow to get data, Room adds a trigger to watch for database changes, and emits a new list of movies. Because the UI is collecting from that Flow, the list on screen automatically updates:

movie list with data


All code changes

CHANGED: app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android { namespace = "com.androidbyexample.compose.movies" compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "com.androidbyexample.compose.movies" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } }
compileOptions { sourceCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get()) targetCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get()) } kotlinOptions { jvmTarget = libs.versions.jvmTarget.get() }
buildFeatures { compose = true } } dependencies {
implementation(project(":repository"))
implementation(libs.lifecycle.compose)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) }
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/MainActivity.kt
package com.androidbyexample.compose.movies

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import com.androidbyexample.compose.movies.screens.Ui
import com.androidbyexample.compose.movies.ui.theme.MoviesTheme

class MainActivity : ComponentActivity() {
// private val viewModel by viewModels<MovieViewModel>() private val viewModel by viewModels<MovieViewModel> { MovieViewModel.Factory }
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { MoviesTheme { Ui( viewModel = viewModel, ) { finish() } } } } }
DELETED: app/src/main/java/com/androidbyexample/compose/movies/Movie.kt
//package com.androidbyexample.compose.movies
//
//data class Movie(
//  val title: String,
//  val description: String,
//)
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/MovieViewModel.kt
package com.androidbyexample.compose.movies

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import com.androidbyexample.compose.movies.repository.MovieDatabaseRepository
import com.androidbyexample.compose.movies.repository.MovieRepository
import com.androidbyexample.compose.movies.screens.MovieList
import com.androidbyexample.compose.movies.screens.Screen

class MovieViewModel(
private val repository: MovieRepository,
): ViewModel(), MovieRepository by repository {
private var screenStack = listOf<Screen>(MovieList) set(value) { field = value currentScreen = value.lastOrNull() } var currentScreen by mutableStateOf<Screen?>(MovieList) private set fun pushScreen(screen: Screen) { screenStack = screenStack + screen } fun popScreen() { screenStack = screenStack.dropLast(1) }
// val movies: List<Movie> = listOf( // Movie("The Transporter", "Jason Statham kicks a guy in the face"), // Movie("Transporter 2", "Jason Statham kicks a bunch of guys in the face"), // Movie("Hobbs and Shaw", "Cars, Explosions and Stuff"), // Movie("Jumanji - Welcome to the Jungle", "The Rock smolders"), // ) companion object { val Factory: ViewModelProvider.Factory = object: ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create( modelClass: Class<T>, extras: CreationExtras ): T { // Get the Application object from extras val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) return MovieViewModel( MovieDatabaseRepository.create(application) ) as T } } }
}
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/screens/MovieDisplay.kt
package com.androidbyexample.compose.movies.screens

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
//import com.androidbyexample.compose.movies.Movie
import com.androidbyexample.compose.movies.R
import com.androidbyexample.compose.movies.components.Display
import com.androidbyexample.compose.movies.components.Label
import com.androidbyexample.compose.movies.repository.MovieDto

@OptIn(ExperimentalMaterial3Api::class) // for TopAppBar
@Composable
fun MovieDisplayUi(
// movie: Movie, movie: MovieDto,
modifier: Modifier = Modifier, ) { Scaffold( topBar = { TopAppBar( title = { Text(text = movie.title) } ) }, modifier = modifier, ) { innerPadding -> Column ( modifier = Modifier .padding(innerPadding) .verticalScroll(rememberScrollState()) ) { Label (textId = R.string.title) Display(text = movie.title) Label(textId = R.string.description) Display(text = movie.description) } } }
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
package com.androidbyexample.compose.movies.screens

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
//import com.androidbyexample.compose.movies.Movie
import com.androidbyexample.compose.movies.R
import com.androidbyexample.compose.movies.components.Display
import com.androidbyexample.compose.movies.repository.MovieDto

@OptIn(ExperimentalMaterial3Api::class) // for TopAppBar
@Composable
fun MovieListUi(
// movies: List<Movie>, movies: List<MovieDto>, modifier: Modifier = Modifier, // onMovieClicked: (Movie) -> Unit, onMovieClicked: (MovieDto) -> Unit,
onResetDatabase: () -> Unit,
) { Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(R.string.movies)) },
actions = { IconButton (onClick = onResetDatabase) { Icon( imageVector = Icons.Default.Refresh, contentDescription = stringResource(R.string.reset_database) ) } }
) }, modifier = modifier, ) { innerPadding -> Column ( modifier = Modifier .padding(innerPadding) .fillMaxSize() .verticalScroll(rememberScrollState()) ) { movies.forEach { movie -> Card ( elevation = CardDefaults.cardElevation( defaultElevation = 8.dp, ), onClick = { onMovieClicked(movie) }, modifier = Modifier.padding(8.dp) ) { Row ( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp) ) { Icon( imageVector = Icons.Default.Star, contentDescription = stringResource(id = R.string.movie) ) Display(text = movie.title) } } } } } }
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/screens/Screens.kt
package com.androidbyexample.compose.movies.screens

//import com.androidbyexample.compose.movies.Movie
import com.androidbyexample.compose.movies.repository.MovieDto

sealed interface Screen
data object MovieList: Screen
//data class MovieDisplay(val movie: Movie): Screen data class MovieDisplay(val movie: MovieDto): Screen
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/screens/Ui.kt
package com.androidbyexample.compose.movies.screens

import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.androidbyexample.compose.movies.MovieViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

@Composable
fun Ui(
    viewModel: MovieViewModel,
    modifier: Modifier = Modifier,
    onExit: () -> Unit,
) {
    BackHandler {
        viewModel.popScreen()
    }

val scope = rememberCoroutineScope()
when (val screen = viewModel.currentScreen) { null -> onExit() is MovieDisplay -> { MovieDisplayUi( movie = screen.movie, modifier = modifier, ) } MovieList -> {
val movies by viewModel.moviesFlow.collectAsStateWithLifecycle( initialValue = emptyList() ) MovieListUi( // movies = viewModel.movies, movies = movies,
modifier = modifier,
// ) { movie -> onResetDatabase = { scope.launch (Dispatchers.IO) { viewModel.resetDatabase() } }, onMovieClicked = { movie -> viewModel.pushScreen(MovieDisplay(movie)) }
) } } }
CHANGED: app/src/main/res/values/strings.xml
<resources>
    <string name="app_name">Movies</string>
    <string name="movies">Movies</string>
    <string name="title">Title</string>
    <string name="description">Description</string>
    <string name="movie">Movie</string>
    <string name="reset_database">Reset Database</string>
</resources>
CHANGED: gradle/libs.versions.toml
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
appcompat = "1.7.0"
material = "1.12.0"
room = "2.6.1" ksp = "2.0.21-1.0.28"
compileSdk = "35" targetSdk = "35" minSdk = "24"
jvmTarget = "11" javaVersion = "VERSION_11"
lifecycle-compose = "2.8.7" [libraries] lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-compose" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
[plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
CHANGED: repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieDatabaseRepository.kt
package com.androidbyexample.compose.movies.repository

import android.content.Context
import com.androidbyexample.compose.movies.data.MovieDao
import com.androidbyexample.compose.movies.data.createDao
import kotlinx.coroutines.flow.map

class MovieDatabaseRepository( private val dao: MovieDao ): MovieRepository { override val ratingsFlow = dao.getRatingsFlow() .map { ratings ->// for each List<RatingEntity> that's emitted // create a list of RatingDto ratings.map { rating -> rating.toDto() } // map each entity to Dto } override val moviesFlow = dao.getMoviesFlow() .map { movies -> movies.map { it.toDto() } } override val actorsFlow = dao.getActorsFlow() .map { actors -> actors.map { it.toDto() } } override suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto = dao.getRatingWithMovies(id).toDto() override suspend fun getMovieWithCast(id: String): MovieWithCastDto = dao.getMovieWithCast(id).toDto() override suspend fun getActorWithFilmography(id: String): ActorWithFilmographyDto = dao.getActorWithFilmography(id).toDto() override suspend fun insert(movie: MovieDto) = dao.insert(movie.toEntity()) override suspend fun insert(actor: ActorDto) = dao.insert(actor.toEntity()) override suspend fun insert(rating: RatingDto) = dao.insert(rating.toEntity()) override suspend fun resetDatabase() = dao.resetDatabase()
companion object { fun create(context: Context) = MovieDatabaseRepository(createDao(context)) }
}