Movies UI - Lists
Contextual Top Bar
Now that we have selections, we can set up a contextial Top Bar to display the number of selections and an action to delete the selected items.
Currently we have static top bar that displays the page title and an action to reset the database. We can make this contextual based on whether or not we have selections.
This requires some additional strings and a new parameter to delete the selected movies when the trash can is clicked.
When there are items selected, the contextual top bar displays the number of selected items, a back arrow to clear the selections (returning to the normal top bar), and a trash can icon to delete selected items.
We added code to clear selections when the back arrow is pressed, but what if the user performs a back gesture (or presses the back button)? We can
handle back
using a BackHandler
(surprise, surprise), calling onClearSelections
.
This back handler is only set up if there are any selected ids, and will override the back handler set up in Ui
.
Now we need to add "delete by ids" support:
- Add delete by id support to the Dao
- Expose these in
MovieRepository
- Pass them through in in the
MovieDatabaseRepository
- They'll automatically be passed through the view model (because we delegated
MovieRepository
) - Add
deleteSelectedXXX
functions for movies, actors and ratings to the view model. - Pass
deleteSelectedMovies()
as the function to use foronDeleteSelectedMovies
Note
The deleteSelectedXXX
functions in the view model launch a coroutine using the viewModelScope
. This coroutine scope lives as long as the view model lives. If we used rememberCoroutineScope
in MovieListUi
, any coroutines launched would be canceled when the user leaves the screen.
Phew - that's a long chain of deletes coming from the data layer! You shouldn't have to uninstall the app or increase the database version, as we only modified the DAO.
When we run the application, we can now select movies and delete them using the trash can button. The list is automatically updated because we used a Flow
as the return type for the getting the movie list. When the database has been changed, the getMoviesFlow
query is re-executed, and the results emitted to the same Flow
. The Ui
collects the Flow
into Compose state. Compose detects the state change and recomposes the UI. Poof!
Code Changes
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.ViewModelProvider import androidx.lifecycle.viewModelScopeimport androidx.lifecycle.viewmodel.CreationExtras import com.androidbyexample.movie.repository.MovieDatabaseRepository import com.androidbyexample.movie.repository.MovieRepository import com.androidbyexample.movie.screens.MovieList import com.androidbyexample.movie.screens.Screen import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class MovieViewModel( private val repository: MovieRepository, ): ViewModel(), MovieRepository by repository {private val _selectedIdsFlow = MutableStateFlow<Set<String>>(emptySet()) val selectedIdsFlow: Flow<Set<String>> = _selectedIdsFlow.asStateFlow()fun clearSelectedIds() { _selectedIdsFlow.value = emptySet() } fun toggleSelection(id: String) { if (id in _selectedIdsFlow.value) { _selectedIdsFlow.value -= id } else { _selectedIdsFlow.value += id } }private var screenStack = listOf<Screen>(MovieList) set(value) { field = valueclearSelectedIds()currentScreen = value.lastOrNull() }// NOTE: We're keep this as a Compose State for comparison. // You can use Compose state to expose anything from the view model, // but our example will be using Flow from now on to demonstrate how // the view model can be used without Compose, perhaps for other // platforms such as iOS, desktop, web or command line var currentScreen by mutableStateOf<Screen?>(MovieList) private setfun pushScreen(screen: Screen) { screenStack = screenStack + screen } fun popScreen() { screenStack = screenStack.dropLast(1) }fun deleteSelectedMovies() { viewModelScope.launch { deleteMoviesById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } fun deleteSelectedActors() { viewModelScope.launch { deleteActorsById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } fun deleteSelectedRatings() { viewModelScope.launch { deleteRatingsById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } }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/MovieListUi.kt
package com.androidbyexample.movie.screens import androidx.activity.compose.BackHandlerimport androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBackimport androidx.compose.material.icons.filled.Deleteimport 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.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.contentColorFor 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.R import com.androidbyexample.movie.components.Display import com.androidbyexample.movie.repository.MovieDto @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun MovieListUi( movies: List<MovieDto>, onMovieClicked: (MovieDto) -> Unit,selectedIds: Set<String>, onSelectionToggle: (id: String) -> Unit, onClearSelections: () -> Unit,onResetDatabase: () -> Unit, ) { Scaffold( topBar = {onDeleteSelectedMovies: () -> Unit,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( 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: /app/src/main/res/values/strings.xml
<resources> <string name="app_name">MovieUi2</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> <string name="loading">(loading)</string> <string name="cast">Cast</string> <string name="cast_entry">%1$s: %2$s</string><string name="clear_selections">Clear Selections</string> <string name="delete_selected_items">Delete selected items</string></resources>
CHANGED: /data/src/main/java/com/androidbyexample/movie/data/MovieDao.kt
package com.androidbyexample.movie.data import androidx.room.Dao import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction import kotlinx.coroutines.flow.Flow @Dao abstract class MovieDao { @Query("SELECT * FROM RatingEntity") abstract fun getRatingsFlow(): Flow<List<RatingEntity>> @Query("SELECT * FROM MovieEntity") abstract fun getMoviesFlow(): Flow<List<MovieEntity>> @Query("SELECT * FROM ActorEntity") abstract fun getActorsFlow(): Flow<List<ActorEntity>> @Transaction @Query("SELECT * FROM RatingEntity WHERE id = :id") abstract suspend fun getRatingWithMovies(id: String): RatingWithMovies @Transaction @Query("SELECT * FROM ActorEntity WHERE id = :id") abstract suspend fun getActorWithFilmography(id: String): ActorWithFilmography @Transaction @Query("SELECT * FROM MovieEntity WHERE id = :id") abstract suspend fun getMovieWithCast(id: String): MovieWithCast @Insert abstract suspend fun insert(vararg ratings: RatingEntity) @Insert abstract suspend fun insert(vararg movies: MovieEntity) @Insert abstract suspend fun insert(vararg actors: ActorEntity) @Insert abstract suspend fun insert(vararg roles: RoleEntity)@Query("DELETE FROM MovieEntity WHERE id IN (:ids)") abstract suspend fun deleteMoviesById(ids: Set<String>) @Query("DELETE FROM ActorEntity WHERE id IN (:ids)") abstract suspend fun deleteActorsById(ids: Set<String>) @Query("DELETE FROM RatingEntity WHERE id IN (:ids)") abstract suspend fun deleteRatingsById(ids: Set<String>)@Query("DELETE FROM MovieEntity") abstract suspend fun clearMovies() @Query("DELETE FROM ActorEntity") abstract suspend fun clearActors() @Query("DELETE FROM RatingEntity") abstract suspend fun clearRatings() @Query("DELETE FROM RoleEntity") abstract suspend fun clearRoles() @Transaction open suspend fun resetDatabase() { clearMovies() clearActors() clearRoles() clearRatings() insert( RatingEntity(id = "r0", name = "Not Rated", description = "Not yet rated"), RatingEntity(id = "r1", name = "G", description = "General Audiences"), RatingEntity(id = "r2", name = "PG", description = "Parental Guidance Suggested"), RatingEntity(id = "r3", name = "PG-13", description = "Unsuitable for those under 13"), RatingEntity(id = "r4", name = "R", description = "Restricted - 17 and older"), ) insert( MovieEntity("m1", "The Transporter", "Jason Statham kicks a guy in the face", "r3"), MovieEntity("m2", "Transporter 2", "Jason Statham kicks a bunch of guys in the face", "r4"), MovieEntity("m3", "Hobbs and Shaw", "Cars, Explosions and Stuff", "r3"), MovieEntity("m4", "Jumanji - Welcome to the Jungle", "The Rock smolders", "r3"), ) insert( ActorEntity("a1", "Jason Statham"), ActorEntity("a2", "The Rock"), ActorEntity("a3", "Shu Qi"), ActorEntity("a4", "Amber Valletta"), ActorEntity("a5", "Kevin Hart"), ) insert( RoleEntity("m1", "a1", "Frank Martin", 1), RoleEntity("m1", "a3", "Lai", 2), RoleEntity("m2", "a1", "Frank Martin", 1), RoleEntity("m2", "a4", "Audrey Billings", 2), RoleEntity("m3", "a2", "Hobbs", 1), RoleEntity("m3", "a1", "Shaw", 2), RoleEntity("m4", "a2", "Spencer", 1), RoleEntity("m4", "a5", "Fridge", 2), ) } }
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 deleteMoviesById(ids: Set<String>) = dao.deleteMoviesById(ids) override suspend fun deleteActorsById(ids: Set<String>) = dao.deleteActorsById(ids) override suspend fun deleteRatingsById(ids: Set<String>) = dao.deleteRatingsById(ids)override suspend fun resetDatabase() = dao.resetDatabase() companion object { fun create(context: Context) = MovieDatabaseRepository(createDao(context)) } }
CHANGED: /repository/src/main/java/com/androidbyexample/movie/repository/MovieRepository.kt
package com.androidbyexample.movie.repository import kotlinx.coroutines.flow.Flow interface MovieRepository { val ratingsFlow: Flow<List<RatingDto>> val moviesFlow: Flow<List<MovieDto>> val actorsFlow: Flow<List<ActorDto>> suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto suspend fun getMovieWithCast(id: String): MovieWithCastDto suspend fun getActorWithFilmography(id: String): ActorWithFilmographyDto suspend fun insert(movie: MovieDto) suspend fun insert(actor: ActorDto) suspend fun insert(rating: RatingDto)suspend fun deleteMoviesById(ids: Set<String>) suspend fun deleteActorsById(ids: Set<String>) suspend fun deleteRatingsById(ids: Set<String>)suspend fun resetDatabase() }