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/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.

show in full file app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
// ...
class MovieViewModel(
    // ...
): ViewModel(), MovieRepository by repository {
    // ...
    }

    companion object {
        val Factory = viewModelFactory {
            initializer {
                val application =
                    checkNotNull(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY])
                val savedStateHandle = this.createSavedStateHandle()
                MovieViewModel(
                    savedStateHandle = savedStateHandle,
                    repository = MovieDatabaseRepository.create(application)
                )
            }
        }
    }
}

We then use this factory by passing it in a lambda in the viewModels call.

show in full file app/src/main/java/com/androidbyexample/movies/MainActivity.kt
// ...

class MainActivity : ComponentActivity() {
//  private val viewModel by viewModels<MovieViewModel>()
    private val viewModel by viewModels<MovieViewModel> { MovieViewModel.Factory }

    override fun onCreate(savedInstanceState: Bundle?) {
        // ...
}

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/movies/screens/MovieListUi.kt
// ...

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

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieDisplayUi(
//  movie: Movie,
    movie: MovieDto,
    modifier: Modifier = Modifier,
) {
    // ...
}
show in full file app/src/main/java/com/androidbyexample/movies/screens/Screens.kt
// ...

@Parcelize
//data class MovieDisplay(val movie: Movie): Screen
data class MovieDisplay(val movie: MovieDto): Screen

Note

Our current setup requires the passed-in movie to be Parcelable. MovieDto isn't. We temporarily need to add the kotlin-parcelize plugin in the repository module and mark MovieDto with @Parcelize.

show in full file repository/build.gradle.kts
plugins {
    alias(libs.plugins.android.library)
    id("kotlin-parcelize")
}

// ...
show in full file repository/src/main/java/com/androidbyexample/movies/repository/MovieDto.kt
// ...
import kotlinx.parcelize.Parcelize

@Parcelize
data class MovieDto(
    val id: String,
    val title: String,
    val description: String,
    val ratingId: String,
//)
): Parcelable

internal fun MovieEntity.toDto() =
    // ...

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.

Let's 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/movies/screens/Ui.kt
// ...

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
    // ...
) {
    // ...
    NavDisplay(
        // ...
        entryProvider = entryProvider {
            entry<MovieList>(
                metadata = ListDetailSceneStrategy.listPane()
            ) {
                val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(
                    initialValue = emptyList()
                )

                MovieListUi(
//                  movies = viewModel.movies,
                    movies = movies,
                    onMovieClicked = { movie ->
                        viewModel.pushScreen(MovieDisplay(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/movies/screens/MovieListUi.kt
// ...

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieListUi(
    // ...
    onMovieClicked: (MovieDto) -> Unit,
    onResetDatabase: () -> Unit,
) {
    Scaffold(
        topBar = {
            TopAppBar(
                // ...
                    Text(text = stringResource(R.string.movies))
                },
                actions = {
                    IconButton(onClick = onResetDatabase) {
                        Icon(
                            painter = painterResource(R.drawable.refresh_24),
                            contentDescription = stringResource(R.string.reset_database),
                        )
                    }
                }
            )
        },
        // ...
    ) { innerPadding ->
        // ...
    }
}

We can get an icon from https://fonts.google.com/icons. Let's use the "Refresh" icon. Download the SVG for it, and use the Resource Manager to import it like we did for the Movie icon. See Flesh out the screens.

Our resetDatabase() function in the DAO, repository and view model is defined as a suspend function, meaning it must be executed in a coroutine. We could grab a coroutine scope by calling rememeberCoroutineScope(), but if the user rotates the screen while the update is in progress, the call will be canceled.

Instead, we'll add a helper function in the view model to use its coroutine scope to launch the update.

show in full file app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
// ...
class MovieViewModel(
    // ...
): ViewModel(), MovieRepository by repository {
    // ...
    }

    fun doResetDatabase() {
        viewModelScope.launch(Dispatchers.IO) {
            repository.resetDatabase()
        }
    }

    companion object {
        // ...
}

This coroutine won't be canceled unless the activity is destroyed, which will kill the view model.

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

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
    // ...
) {
    // ...
    NavDisplay(
        // ...
        entryProvider = entryProvider {
            entry<MovieList>(
                // ...
            ) {
                // ...
                MovieListUi(
                    // ...
                        viewModel.pushScreen(MovieDisplay(movie))
                    },
                    onResetDatabase = viewModel::doResetDatabase,
                )
            }
            // ...
        }
    )
}

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/src/main/java/com/androidbyexample/movies/MainActivity.kt
package com.androidbyexample.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.movies.screens.Ui
import com.androidbyexample.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) } } } }
CHANGED: app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
package com.androidbyexample.movies

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.androidbyexample.movies.repository.MovieDatabaseRepository
import com.androidbyexample.movies.repository.MovieRepository
import com.androidbyexample.movies.screens.MovieList
import com.androidbyexample.movies.screens.Screen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class MovieViewModel( private val savedStateHandle: SavedStateHandle, private val repository: MovieRepository, ): ViewModel(), MovieRepository by repository { val backStackFlow = savedStateHandle.getMutableStateFlow<List<Screen>>( key = "back_stack", initialValue = listOf(MovieList) ) fun pushScreen(screen: Screen) { backStackFlow.value += screen } fun popScreen() { backStackFlow.value = backStackFlow.value.dropLast(1) }
fun doResetDatabase() { viewModelScope.launch(Dispatchers.IO) { repository.resetDatabase() } }
companion object { val Factory = viewModelFactory { initializer { val application = checkNotNull(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) val savedStateHandle = this.createSavedStateHandle() MovieViewModel( savedStateHandle = savedStateHandle, repository = MovieDatabaseRepository.create(application) ) } } }
}
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/MovieDisplayUi.kt
package com.androidbyexample.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.movies.R
//import com.androidbyexample.movies.data.Movie
import com.androidbyexample.movies.helper.Display
import com.androidbyexample.movies.helper.Label
import com.androidbyexample.movies.repository.MovieDto

@OptIn(ExperimentalMaterial3Api::class)
@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/movies/screens/MovieListUi.kt
package com.androidbyexample.movies.screens

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.androidbyexample.movies.R
//import com.androidbyexample.movies.data.Movie
import com.androidbyexample.movies.helper.Display
import com.androidbyexample.movies.repository.MovieDto

@OptIn(ExperimentalMaterial3Api::class)
@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 = "Movies") Text(text = stringResource(R.string.movies)) },
actions = { IconButton(onClick = onResetDatabase) { Icon( painter = painterResource(R.drawable.refresh_24), contentDescription = stringResource(R.string.reset_database), ) } }
) }, modifier = modifier, ) { innerPadding -> Column( modifier = modifier .padding(innerPadding) .verticalScroll(rememberScrollState()) .padding( start = 8.dp, end = 8.dp ) ) { 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( painter = painterResource(R.drawable.movie_24), contentDescription = stringResource(R.string.movie), ) Display(text = movie.title) } } } } } }
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/Screens.kt
package com.androidbyexample.movies.screens

import android.os.Parcelable
//import com.androidbyexample.movies.data.Movie
import com.androidbyexample.movies.repository.MovieDto
import kotlinx.parcelize.Parcelize

sealed interface Screen: Parcelable

@Parcelize
data object MovieList: Screen

@Parcelize //data class MovieDisplay(val movie: Movie): Screen data class MovieDisplay(val movie: MovieDto): Screen
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
package com.androidbyexample.movies.screens

import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.androidbyexample.movies.MovieViewModel

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
    viewModel: MovieViewModel,
) {
    val listDetailStrategy = rememberListDetailSceneStrategy<Screen>()

    val backStack by viewModel.backStackFlow.collectAsStateWithLifecycle(listOf(MovieList))

    NavDisplay(
        backStack = backStack,
        onBack = viewModel::popScreen,
        sceneStrategies = listOf(listDetailStrategy),
        entryProvider = entryProvider {
            entry<MovieList>(
                metadata = ListDetailSceneStrategy.listPane()
            ) {
val movies by viewModel.moviesFlow.collectAsStateWithLifecycle( initialValue = emptyList() ) MovieListUi( // movies = viewModel.movies, movies = movies,
onMovieClicked = { movie -> viewModel.pushScreen(MovieDisplay(movie)) },
onResetDatabase = viewModel::doResetDatabase,
) } entry<MovieDisplay>( metadata = ListDetailSceneStrategy.detailPane() ) { key -> MovieDisplayUi( movie = key.movie, ) } } ) }
ADDED: app/src/main/res/drawable/refresh_24.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="960"
    android:viewportHeight="960">
  <path
      android:pathData="M480,800q-134,0 -227,-93t-93,-227q0,-134 93,-227t227,-93q69,0 132,28.5T720,270v-110h80v280L520,440v-80h168q-32,-56 -87.5,-88T480,240q-100,0 -170,70t-70,170q0,100 70,170t170,70q77,0 139,-44t87,-116h84q-28,106 -114,173t-196,67Z"
      android:fillColor="#e3e3e3"/>
</vector>
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: repository/build.gradle.kts
plugins {
    alias(libs.plugins.android.library)
id("kotlin-parcelize")
} android { namespace = "com.androidbyexample.movies.repository" compileSdk { version = release(libs.versions.compileSdk.get().toInt()) } defaultConfig { minSdk = libs.versions.minSdk.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } compileOptions { sourceCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get()) targetCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get()) } } dependencies {
implementation(project(":data"))
implementation(libs.androidx.appcompat) implementation(libs.androidx.core.ktx) implementation(libs.material) testImplementation(libs.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) }
CHANGED: repository/src/main/java/com/androidbyexample/movies/repository/MovieDatabaseRepository.kt
package com.androidbyexample.movies.repository

import android.content.Context
import com.androidbyexample.movies.data.MovieDao
import com.androidbyexample.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)) }
}
CHANGED: repository/src/main/java/com/androidbyexample/movies/repository/MovieDto.kt
package com.androidbyexample.movies.repository

import android.os.Parcelable
import com.androidbyexample.movies.data.MovieEntity
import com.androidbyexample.movies.data.MovieWithCast
import com.androidbyexample.movies.data.RoleWithActor
import kotlinx.parcelize.Parcelize

@Parcelize data class MovieDto( val id: String, val title: String, val description: String, val ratingId: String, //) ): Parcelable
internal fun MovieEntity.toDto() = MovieDto(id = id, title = title, description = description, ratingId = ratingId) internal fun MovieDto.toEntity() = MovieEntity(id = id, title = title, description = description, ratingId = ratingId) data class MovieWithCastDto( val movie: MovieDto, val cast: List<RoleWithActorDto>, ) data class RoleWithActorDto( val actor: ActorDto, val character: String, val orderInCredits: Int, ) internal fun RoleWithActor.toDto() = RoleWithActorDto( actor = actor.toDto(), character = role.character, orderInCredits = role.orderInCredits, ) internal fun MovieWithCast.toDto() = MovieWithCastDto( movie = movie.toDto(), cast = rolesWithActors.map { it.toDto() } )