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.
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:
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))
},
)
},
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))
}
}