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.
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:
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() {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 ) {// private val viewModel by viewModels<MovieViewModel>()private val viewModel by viewModels<MovieViewModel> { MovieViewModel.Factory }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.Screenclass MovieViewModel(private val repository: MovieRepository,): ViewModel(), MovieRepository by repository {private var screenStack = listOf<Screen>(MovieList) set(value) { field = valuecurrentScreen = value.lastOrNull()}var currentScreen by mutableStateOf<Screen?>(MovieList) private setfun 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)) }, ) }, 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.MovieDtosealed 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( movies = movies, onMovieClicked = { movie -> viewModel.pushScreen(MovieDisplay(movie)) },// MovieListUi(viewModel.movies) { movie ->val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(initialValue = emptyList())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.mapclass 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)) }}