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 has

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

The 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 call 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.

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.

The collectAsState() function is defined by Compose 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 to app/build.gradle.kts.

Now we can add the collection code.

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

Our resetDatabase() function 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.

Finally, we call resetDatabase() in our view model inside a coroutine.

Note

I restructured the call to MovieListUI's constructor because it now has multiple even 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 main 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.

(An exception to this advice - any Composable that has a content parameter at the end)

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

Code Changes

CHANGED: /app/build.gradle.kts
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
    alias(libs.plugins.androidApplication)
    alias(libs.plugins.kotlinAndroid)
}

kotlin { jvmToolchain(17) }
android { namespace = "com.androidbyexample.movie" compileSdk = 34 defaultConfig { applicationId = "com.androidbyexample.moviedb" minSdk = 24 targetSdk = 34 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) }
} buildFeatures { compose = true }
composeOptions { kotlinCompilerExtensionVersion = "1.5.2" } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } } dependencies {
implementation(project(":repository"))
implementation(libs.lifecycle.compose)
implementation(libs.core.ktx) implementation(libs.lifecycle.runtime.ktx) implementation(libs.activity.compose) implementation(platform(libs.compose.bom)) implementation(libs.ui) implementation(libs.ui.graphics) implementation(libs.ui.tooling.preview) implementation(libs.material3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.ui.test.junit4) debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.test.manifest) }
CHANGED: /app/src/main/java/com/androidbyexample/movie/MainActivity.kt
package com.androidbyexample.movie

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.androidbyexample.movie.screens.Ui
import com.androidbyexample.movie.ui.theme.MovieUi1Theme

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) setContent { MovieUi1Theme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) {
Ui(viewModel) { finish() }
} } } } }
CHANGED: /app/src/main/java/com/androidbyexample/movie/MovieViewModel.kt
package com.androidbyexample.movie

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProviderimport androidx.lifecycle.viewmodel.CreationExtrasimport com.androidbyexample.movie.repository.ActorDto
import com.androidbyexample.movie.repository.MovieDatabaseRepositoryimport com.androidbyexample.movie.repository.MovieRepository
import com.androidbyexample.movie.repository.MovieWithCastDto
import com.androidbyexample.movie.screens.MovieList
import com.androidbyexample.movie.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) }
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/movie/screens/MovieDisplayUi.kt
package com.androidbyexample.movie.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.movie.Movieimport com.androidbyexample.movie.R
import com.androidbyexample.movie.components.Display
import com.androidbyexample.movie.components.Label
import com.androidbyexample.movie.repository.MovieDto
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieDisplayUi(
// movie: Movie, movie: MovieDto,
) {
Scaffold(
topBar = { TopAppBar( title = { Text(text = movie.title) } ) }
) { paddingValues ->
Column( modifier = Modifier .padding(paddingValues) .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/movie/screens/MovieListUi.kt
package com.androidbyexample.movie.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.Refreshimport 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.IconButtonimport 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.movie.Movieimport com.androidbyexample.movie.R
import com.androidbyexample.movie.components.Display
import com.androidbyexample.movie.repository.MovieDto
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieListUi(
// movies: List<Movie>,// onMovieClicked: (Movie) -> Unit, movies: List<MovieDto>, onMovieClicked: (MovieDto) -> Unit,
onResetDatabase: () -> Unit,
) { Scaffold( topBar = { TopAppBar( // title = { Text(text = stringResource(R.string.movies)) } title = { Text(text = stringResource(R.string.movies)) },
actions = { IconButton(onClick = onResetDatabase) { Icon( imageVector = Icons.Default.Refresh, contentDescription = stringResource(R.string.reset_database) ) } }
) }, modifier = Modifier.fillMaxSize() ) { paddingValues -> Column( modifier = Modifier .padding(paddingValues) .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/movie/screens/Screens.kt
package com.androidbyexample.movie.screens

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

import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValueimport androidx.compose.runtime.rememberCoroutineScopeimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport com.androidbyexample.movie.MovieViewModel
import kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.launch
@Composable fun Ui( viewModel: MovieViewModel, onExit: () -> Unit, ) {
BackHandler { viewModel.popScreen() }
val scope = rememberCoroutineScope()
when (val screen = viewModel.currentScreen) { null -> onExit() is MovieDisplay -> { MovieDisplayUi(screen.movie) } MovieList -> {
// MovieListUi(viewModel.movies) { movie -> val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(initialValue = emptyList())
MovieListUi( movies = movies, onMovieClicked = { movie -> viewModel.pushScreen(MovieDisplay(movie)) },
onResetDatabase = { scope.launch(Dispatchers.IO) { viewModel.resetDatabase() } }
) } }
}
CHANGED: /app/src/main/res/values/strings.xml
<resources> <string name="app_name">MovieUi1</string> <string name="movies">Movies</string> <string name="movie">Movie</string> <string name="title">Title</string> <string name="description">Description</string> <string name="reset_database">Reset Database</string></resources>
CHANGED: /gradle/libs.versions.toml
[versions]
agp = "8.2.0-beta03"
kotlin = "1.9.0"
core-ktx = "1.12.0"
junit = "4.13.2"
androidx-test-ext-junit = "1.1.5"
espresso-core = "3.5.1"
lifecycle-runtime-ktx = "2.6.2"
activity-compose = "1.7.2"
lifecycle-compose = "2.6.2"compose-bom = "2023.09.00"
appcompat = "1.6.1"
material = "1.9.0"
room = "2.5.2" ksp = "1.9.0-1.0.13"
[libraries] core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-compose" }
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } ui = { group = "androidx.compose.ui", name = "ui" } ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } material3 = { group = "androidx.compose.material3", name = "material3" } 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] androidApplication = { id = "com.android.application", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } androidLibrary = { id = "com.android.library", version.ref = "agp" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
CHANGED: /repository/src/main/java/com/androidbyexample/movie/repository/MovieDatabaseRepository.kt
package com.androidbyexample.movie.repository

import android.content.Context
import com.androidbyexample.movie.data.MovieDao
import com.androidbyexample.movie.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)) }
}