Movies UI - Lists
Contextual Top Bar
Now that we have selections, we can set up a contextual 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.
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
// ...
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) // for TopAppBar
@Composable
fun MovieListUi(
// ...
) {
Scaffold(
topBar = {
if (selectedIds.isEmpty()) {
TopAppBar(
// title = {
// Text(text = stringResource(R.string.movies))
// },
title = { Text(text = stringResource(R.string.movies)) },
actions = {
// IconButton (onClick = onResetDatabase) {
IconButton(onClick = onResetDatabase) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.reset_database)
)
}
}
)
} else {
TopAppBar(
navigationIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.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,
) { innerPadding ->
// ...
}
}
This requires some additional strings
show in full file app/src/main/res/values/strings.xml
<resources>
// ...
<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>
and a new parameter to delete the selected movies when the trash can is clicked.
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
// ...
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) // for TopAppBar
@Composable
fun MovieListUi(
// ...
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
onDeleteSelectedMovies: () -> Unit,
onResetDatabase: () -> Unit,
) {
// ...
}
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
.
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
// ...
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) // for TopAppBar
@Composable
fun MovieListUi(
// ...
) {
Scaffold(
// ...
) { innerPadding ->
LazyColumn (
// ...
) {
items(
// ...
) { movie ->
// ...
val contentColor = MaterialTheme.colorScheme.contentColorFor(containerColor)
if (selectedIds.isNotEmpty()) {
BackHandler {
onClearSelections()
}
}
Card (
// ...
}
}
}
}
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
show in full file data/src/main/java/com/androidbyexample/compose/movies/data/MovieDao.kt
// ...
@Dao
abstract class MovieDao {
// ...
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")
// ...
}
Expose these in MovieRepository
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieRepository.kt
// ...
interface MovieRepository {
// ...
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()
}
Pass them through in the MovieDatabaseRepository
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieDatabaseRepository.kt
// ...
class MovieDatabaseRepository(
// ...
): MovieRepository {
// ...
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)
// ...
}
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.
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.
show in full file app/src/main/java/com/androidbyexample/compose/movies/MovieViewModel.kt
// ...
class MovieViewModel(
// ...
): ViewModel(), MovieRepository by repository {
// ...
}
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()
}
}
// ...
}
Pass deleteSelectedMovies()
as the function to use for onDeleteSelectedMovies
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/Ui.kt
// ...
@Composable
fun Ui(
// ...
) {
// ...
when (val screen = viewModel.currentScreen) {
// ...
MovieList -> {
// ...
MovieListUi(
// ...
onClearSelections = viewModel::clearSelectedIds,
onSelectionToggle = viewModel::toggleSelection,
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
onResetDatabase = {
scope.launch (Dispatchers.IO) {
// ...
)
}
}
}
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!
All code changes
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.viewModelScope
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
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/compose/movies/screens/MovieList.kt
package com.androidbyexample.compose.movies.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.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.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.compose.movies.R
import com.androidbyexample.compose.movies.components.Display
import com.androidbyexample.compose.movies.repository.MovieDto
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) // for TopAppBar
@Composable
fun MovieListUi(
movies: List<MovieDto>,
modifier: Modifier = Modifier,
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))
// },
title = { Text(text = stringResource(R.string.movies)) },
actions = {
// IconButton (onClick = onResetDatabase) {
IconButton(onClick = onResetDatabase) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.reset_database)
)
}
}
)
} else {
TopAppBar(
navigationIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.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,
) { innerPadding ->
LazyColumn (
modifier = Modifier
.padding(innerPadding)
.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/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(
id = screen.id,
fetchMovie = viewModel::getMovieWithCast,
modifier = modifier,
)
}
MovieList -> {
val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
val selectedIds by viewModel
.selectedIdsFlow
.collectAsStateWithLifecycle(initialValue = emptySet())
MovieListUi(
movies = movies,
modifier = modifier,
selectedIds = selectedIds,
onClearSelections = viewModel::clearSelectedIds,
onSelectionToggle = viewModel::toggleSelection,
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
onResetDatabase = {
scope.launch (Dispatchers.IO) {
viewModel.resetDatabase()
}
},
onMovieClicked = { movie ->
viewModel.pushScreen(MovieDisplay(movie.id))
}
)
}
}
}
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>
<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/compose/movies/data/MovieDao.kt
package com.androidbyexample.compose.movies.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/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 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/compose/movies/repository/MovieRepository.kt
package com.androidbyexample.compose.movies.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()
}