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.

Contextual top bar

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:

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 = value
clearSelectedIds()
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 set
fun 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,
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( 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,
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
onResetDatabase = { scope.launch(Dispatchers.IO) { viewModel.resetDatabase() } } ) } } }
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() }