Movies UI - Lists
Refactor time!
Our app bakes the list support in with the Movie UI. We can make it much more reusable!
We're going to have two types of lists in the application:
- Top-level lists of all movies, actors and ratings
- Nested lists, such as actors starring in a movie (on the movie's display screen)
To create consistent list support, we need to separate the LazyColumn
from the Scaffold
and the MovieListUi
. We want to keep all of the selection management, but make it more generic.
But there's a problem. If we make a generic List
composable, something like
fun <T> List(
items: List<T>,
...
) {
...
}
we have several spots that access the item's id
. When the item type was explicitly MovieDto
, we knew it had an id, but if the item type is a generic parameter T
, we can no longer make that assumption.
To fix this, we create a
HasId
interface
in the repository
module. (You could create it in the data
module, but we don't have a need for it that low.) We apply it to our
MovieDto
,
ActorDto
, and
RatingDto
.
We can now create a
generic List
composable.
We can now replace the common list function in MovieListUi
.
But now we can also use this list for the
cast display
in MovieDisplayUi
, giving us a consistent-looking list... with a couple of hitches:
RoleWithActorDto
doesn't implementHasId
MovieDisplayUi
doesn't have a parameter to handle when an actor has been clicked- We don't want selections in the cast list, just clicks.
First, the HasId
. We can add a
derived id
to RoleWithActorDto
. The id here is a combination of movie id & character. (This might not be unique, but for our example purposes it's good enough).
Next, we add a parameter to handle
clicks on roles and just a for now
empty handler for now in Ui
. (We'll add that navigation after we set up the other screens later.)
Code Changes
ADDED: /app/src/main/java/com/androidbyexample/movie/screens/List.kt
package com.androidbyexample.movie.screensimport androidx.activity.compose.BackHandlerimport androidx.compose.foundation.ExperimentalFoundationApiimport androidx.compose.foundation.combinedClickableimport androidx.compose.foundation.layout.ColumnScopeimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.foundation.lazy.itemsimport androidx.compose.material3.Cardimport androidx.compose.material3.CardDefaultsimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.contentColorForimport androidx.compose.runtime.Composableimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport com.androidbyexample.movie.repository.HasId@OptIn(ExperimentalFoundationApi::class)@Composablefun <T: HasId> List( items: List<T>, onItemClicked: (T) -> Unit, selectedIds: Set<String>, onSelectionToggle: (id: String) -> Unit, onClearSelections: () -> Unit, modifier: Modifier = Modifier, itemContent: @Composable ColumnScope.(T) -> Unit,) { LazyColumn( modifier = modifier ) { items( items = items, key = { it.id }, ) { item -> val containerColor = if (item.id in selectedIds) { MaterialTheme.colorScheme.secondary } else { MaterialTheme.colorScheme.surface } val contentColor = MaterialTheme.colorScheme.contentColorFor(containerColor) if (selectedIds.isNotEmpty()) { BackHandler { onClearSelections() } } Card( elevation = CardDefaults.cardElevation( defaultElevation = 8.dp, ), colors = CardDefaults.cardColors( containerColor = containerColor, contentColor = contentColor, ), modifier = Modifier .padding(8.dp) .combinedClickable( onClick = { if (selectedIds.isEmpty()) { onItemClicked(item) } else { onSelectionToggle(item.id) } }, onLongClick = { onSelectionToggle(item.id) }, ) ) { itemContent(item) } } }}
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.fillMaxSizeimport 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.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.androidbyexample.movie.R import com.androidbyexample.movie.components.Display import com.androidbyexample.movie.components.Label import com.androidbyexample.movie.repository.MovieWithCastDto import com.androidbyexample.movie.repository.RoleWithActorDtoimport kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable fun MovieDisplayUi( id: String, fetchMovie: suspend (String) -> MovieWithCastDto,onActorClicked: (RoleWithActorDto) -> Unit,) { var movieWithCast by remember { mutableStateOf<MovieWithCastDto?>(null) } LaunchedEffect(key1 = id) { withContext(Dispatchers.IO) { movieWithCast = fetchMovie(id) } } Scaffold( topBar = { TopAppBar( title = { Text(text = movieWithCast?.movie?.title ?: stringResource(R.string.loading)) } ) } ) { paddingValues -> movieWithCast?.let { movieWithCast -> Column( modifier = Modifier .padding(paddingValues)// .verticalScroll(rememberScrollState())) { Label(textId = R.string.title) Display(text = movieWithCast.movie.title) Label(textId = R.string.description) Display(text = movieWithCast.movie.description) Label(textId = R.string.cast)// movieWithCast// .cast// .sortedBy { it.orderInCredits }// .forEach { role ->List( items = movieWithCast.cast.sortedBy { it.orderInCredits }, onItemClicked = onActorClicked, selectedIds = emptySet(), onSelectionToggle = {}, onClearSelections = {}, modifier = Modifier.weight(1f) ) { role -> Display( text = stringResource( R.string.cast_entry, role.character, role.actor.name, ) ) }} } } }
CHANGED: /app/src/main/java/com/androidbyexample/movie/screens/MovieListUi.kt
package com.androidbyexample.movie.screens//import androidx.activity.compose.BackHandler//import androidx.compose.foundation.ExperimentalFoundationApiimport androidx.compose.foundation.clickable//import androidx.compose.foundation.combinedClickable//import androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding//import androidx.compose.foundation.lazy.LazyColumn//import androidx.compose.foundation.lazy.items//import androidx.compose.foundation.rememberScrollState//import androidx.compose.foundation.verticalScrollimport androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Star//import androidx.compose.material3.Card//import androidx.compose.material3.CardDefaultsimport androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton//import androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar//import androidx.compose.material3.contentColorForimport 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.R import com.androidbyexample.movie.components.Display import com.androidbyexample.movie.repository.MovieDto//@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)@OptIn(ExperimentalMaterial3Api::class)@Composable fun MovieListUi( movies: List<MovieDto>, onMovieClicked: (MovieDto) -> Unit, selectedIds: Set<String>, onSelectionToggle: (id: String) -> Unit, onClearSelections: () -> Unit, onDeleteSelectedMovies: () -> Unit, onResetDatabase: () -> Unit, ) { Scaffold( topBar = { if (selectedIds.isEmpty()) { TopAppBar( title = { Text(text = stringResource(R.string.movies)) }, actions = { IconButton(onClick = onResetDatabase) { Icon( imageVector = Icons.Default.Refresh, contentDescription = stringResource(R.string.reset_database) ) } } ) } else { TopAppBar( navigationIcon = { Icon( imageVector = Icons.Default.ArrowBack, contentDescription = stringResource(R.string.clear_selections), modifier = Modifier.clickable(onClick = onClearSelections), ) }, title = { Text(text = selectedIds.size.toString(), modifier = Modifier.padding(8.dp)) }, actions = { IconButton(onClick = onDeleteSelectedMovies) { Icon( imageVector = Icons.Default.Delete, contentDescription = stringResource(R.string.delete_selected_items) ) } }, ) } }, modifier = Modifier.fillMaxSize() ) { paddingValues ->} }// LazyColumn(List( items = movies, onItemClicked = onMovieClicked, selectedIds = selectedIds, onSelectionToggle = onSelectionToggle, onClearSelections = onClearSelections, modifier = Modifier .padding(paddingValues) .fillMaxSize()// ) {// items(// items = movies,// key = { it.id },) { movie ->// val containerColor =// if (movie.id in selectedIds) {// MaterialTheme.colorScheme.secondary// } else {// MaterialTheme.colorScheme.surface// }// val contentColor = MaterialTheme.colorScheme.contentColorFor(containerColor)// if (selectedIds.isNotEmpty()) {// BackHandler {// onClearSelections()// }// }// Card(// elevation = CardDefaults.cardElevation(// defaultElevation = 8.dp,// ),// colors = CardDefaults.cardColors(// containerColor = containerColor,// contentColor = contentColor,// ),// modifier = Modifier// .padding(8.dp)// .combinedClickable(// onClick = {// if (selectedIds.isEmpty()) {// onMovieClicked(movie)// } else {// onSelectionToggle(movie.id)// }// },// onLongClick = {// onSelectionToggle(movie.id)// },// )// ) {Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp) ) { Icon( imageVector = Icons.Default.Star, contentDescription = stringResource(id = R.string.movie), modifier = Modifier.clickable { onSelectionToggle(movie.id) } ) Display(text = movie.title) } }// }// }
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.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.androidbyexample.movie.MovieViewModel import kotlinx.coroutines.Dispatchers import 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( id = screen.id, fetchMovie = viewModel::getMovieWithCast,) } MovieList -> { val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(initialValue = emptyList())val selectedIds by viewModel.selectedIdsFlow.collectAsStateWithLifecycle(initialValue = emptySet())MovieListUi( movies = movies, onMovieClicked = { movie -> viewModel.pushScreen(MovieDisplay(movie.id)) },selectedIds = selectedIds, onClearSelections = viewModel::clearSelectedIds, onSelectionToggle = viewModel::toggleSelection,onResetDatabase = { scope.launch(Dispatchers.IO) { viewModel.resetDatabase() } } ) } } }onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
CHANGED: /repository/src/main/java/com/androidbyexample/movie/repository/ActorDto.kt
package com.androidbyexample.movie.repository import com.androidbyexample.movie.data.ActorEntity import com.androidbyexample.movie.data.ActorWithFilmography import com.androidbyexample.movie.data.RoleWithMoviedata class ActorDto(internal fun ActorEntity.toDto() = ActorDto(id = id, name = name) internal fun ActorDto.toEntity() = ActorEntity(id = id, name = name) data class ActorWithFilmographyDto( val actor: ActorDto, val filmography: List<RoleWithMovieDto>, ) data class RoleWithMovieDto( val movie: MovieDto, val character: String, val orderInCredits: Int, ) internal fun RoleWithMovie.toDto() = RoleWithMovieDto( movie = movie.toDto(), character = role.character, orderInCredits = role.orderInCredits, ) internal fun ActorWithFilmography.toDto() = ActorWithFilmographyDto( actor = actor.toDto(), filmography = rolesWithMovies.map { it.toDto() } )// val id: String,override val id: String, val name: String,//)): HasId
ADDED: /repository/src/main/java/com/androidbyexample/movie/repository/HasId.kt
package com.androidbyexample.movie.repositoryinterface HasId { val id: String}
CHANGED: /repository/src/main/java/com/androidbyexample/movie/repository/MovieDto.kt
package com.androidbyexample.movie.repository import com.androidbyexample.movie.data.MovieEntity import com.androidbyexample.movie.data.MovieWithCast import com.androidbyexample.movie.data.RoleWithActordata class MovieDto(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>, )// val id: String,override val id: String, val title: String, val description: String, val ratingId: String,//)): HasIddata 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() } )//)): HasId { override val id: String get() = "${actor.id}:$character"}
CHANGED: /repository/src/main/java/com/androidbyexample/movie/repository/RatingDto.kt
package com.androidbyexample.movie.repository import com.androidbyexample.movie.data.RatingEntity import com.androidbyexample.movie.data.RatingWithMoviesdata class RatingDto(internal fun RatingEntity.toDto() = RatingDto(id = id, name = name, description = description) internal fun RatingDto.toEntity() = RatingEntity(id = id, name = name, description = description) data class RatingWithMoviesDto( val rating: RatingDto, val movies: List<MovieDto>, ) // only need the toDto(); we don't use this to do database updates internal fun RatingWithMovies.toDto() = RatingWithMoviesDto( rating = rating.toDto(), movies = movies.map { it.toDto() }, )// val id: String,override val id: String, val name: String, val description: String,//)): HasId