Movies UI - Updates
Editing a movie
Let's edit some movies!
To be able to save the updates, we need to add and expose update functionality.
Add "upsert" functions in the DAO - these will insert if it doesn't exist, update if it does.
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)
@Upsert
abstract suspend fun upsert(vararg ratings: RatingEntity)
@Upsert
abstract suspend fun upsert(vararg movies: MovieEntity)
@Upsert
abstract suspend fun upsert(vararg actors: ActorEntity)
@Upsert
abstract suspend fun upsert(vararg roles: RoleEntity)
// ...
}
Pass the upserts through in the repository repository database implementation
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieRepository.kt
// ...
interface MovieRepository {
// ...
suspend fun insert(rating: RatingDto)
suspend fun upsert(movie: MovieDto)
suspend fun upsert(actor: ActorDto)
suspend fun upsert(rating: RatingDto)
suspend fun deleteMoviesById(ids: Set<String>)
// ...
}
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 upsert(movie: MovieDto) = dao.upsert(movie.toEntity())
override suspend fun upsert(actor: ActorDto) = dao.upsert(actor.toEntity())
override suspend fun upsert(rating: RatingDto) = dao.upsert(rating.toEntity())
override suspend fun deleteMoviesById(ids: Set<String>) = dao.deleteMoviesById(ids)
// ...
}
The view model automatically delegates to the repository, but we need to add update
Flow
s (queues) that will debounce and send the updates
show in full file app/src/main/java/com/androidbyexample/compose/movies/MovieViewModel.kt
// ...
@OptIn(FlowPreview::class)
class MovieViewModel(
// ...
): ViewModel(), MovieRepository by repository {
// ...
}
fun update(movie: MovieDto) {
movieUpdateFlow.value = movie
}
fun update(actor: ActorDto) {
actorUpdateFlow.value = actor
}
fun update(rating: RatingDto) {
ratingUpdateFlow.value = rating
}
// using a debounced flow as a person-update queue
private val movieUpdateFlow = MutableStateFlow<MovieDto?>(null)
private val actorUpdateFlow = MutableStateFlow<ActorDto?>(null)
private val ratingUpdateFlow = MutableStateFlow<RatingDto?>(null)
init {
viewModelScope.launch {
movieUpdateFlow.debounce(500).collect { movie ->
movie?.let { repository.upsert(it) }
}
}
viewModelScope.launch {
actorUpdateFlow.debounce(500).collect { actor ->
actor?.let { repository.upsert(it) }
}
}
viewModelScope.launch {
ratingUpdateFlow.debounce(500).collect { rating ->
rating?.let { repository.upsert(it) }
}
}
}
fun deleteSelectedMovies() {
// ...
}
Note
Keep in mind that Room ensures its suspend functions are run using an IO dispatcher.
It uses a call like withContext(Dispatchers.IO) { ... }
to wrap all work.
This ensures that the calls are "main-thread safe", meaning they don't block the main thread.
Some other libraries will do similar context switching, but you may want to be extra careful
and make your repository calls main-thread safe. For example, you could write
override suspend fun upsert(movie: MovieDto) =
withContext(Dispatchers.IO) {
dao.insert(movie.toEntity())
}
in the MovieDatabaseRepository
, ensuring that the called function is switched to the IO
dispatcher. Not necessary with Room, but if you use other databases, files, or network,
you should do so.
We'll follow the "save to database as we type" approach for the movie edit process.
Add getMovie(id)
to the DAO
show in full file data/src/main/java/com/androidbyexample/compose/movies/data/MovieDao.kt
// ...
@Dao
abstract class MovieDao {
// ...
abstract fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMovies>
@Query("SELECT * FROM MovieEntity WHERE id = :id")
abstract suspend fun getMovie(id: String): MovieEntity
@Insert
// ...
}
Pass through the repository and repository database implementation
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieRepository.kt
// ...
interface MovieRepository {
// ...
fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto>
suspend fun getMovie(id: String): MovieDto
suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto
// ...
}
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieDatabaseRepository.kt
// ...
class MovieDatabaseRepository(
// ...
): MovieRepository {
// ...
.map { it.toDto() }
override suspend fun getMovie(id: String): MovieDto =
dao.getMovie(id).toDto()
override suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto =
// ...
}
Add new screen data for the movie edit screen
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/Screens.kt
// ...
data class ActorDisplay(val id: String): Screen
data class RatingDisplay(val id: String): Screen
data class MovieEdit(val id: String): Screen
Add an event function to ListScaffold
to tell the caller to start editing.
Note the definition of this function:
onEdit: (() -> Unit)? = null,
That might look a bit odd, but what we're defining is a () -> Unit
function that can be
nullable by wrapping the function type in (...)?
, then giving it a default value of null
.
This allows us to pass it if we need it, otherwise its value is null.
Conditionally add an edit button to the ListScaffold
, if onEdit
is not null.
The button will call onEdit
with no parameters, informing the caller that the user wants to edit.
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/ListScaffold.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T: HasId> ListScaffold(
// ...
) {
Scaffold(
topBar = {
if (selectedIds.isEmpty()) {
TopAppBar(
title = { Text(text = stringResource(titleId)) },
actions = {
onEdit?.let {
IconButton(onClick = onEdit) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = stringResource(R.string.edit)
)
}
}
IconButton(onClick = onResetDatabase) {
Icon(
// ...
}
)
} else {
// ...
}
},
// ...
) { paddingValues ->
// ...
}
}
Set up the onEdit
call. This one's a bit tricky:
* If we don't have a movie to edit, movieWithCast
will be null, causing the let
to be null,
resulting in passing null
to the ListScaffold
- no edit button will be displayed.
* If we have a movie to edit, we want to pass a lambda to ListScaffold
for what to do when
the user presses the edit button. The additional curly braces inside the let
define a lambda
expression as the last expression in the let
, which will be returned from the let
and
passed to the ListScaffold
.
* The onEdit
lambda we created in that let
takes the id from the movie in the
currently-displayed MovieWithRoleDto
, and tells the caller to edit it.
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieDisplay.kt
// ...
@Composable
fun MovieDisplayUi(
// ...
onSelectListScreen: (Screen) -> Unit,
onResetDatabase: () -> Unit,
onEdit: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var movieWithCast by remember { mutableStateOf<MovieWithCastDto?>(null) }
LaunchedEffect(key1 = id) {
withContext(Dispatchers.IO) {
movieWithCast = fetchMovie(id)
}
}
ListScaffold(
titleId = R.string.movie,
onEdit = movieWithCast?.let {
{ // if we have a movie, pass this lambda as the edit function (else null from let)
onEdit(it.movie.id)
}
},
items = movieWithCast?.cast?.sortedBy { it.orderInCredits } ?: emptyList(),
onItemClicked = onActorClicked,
// ...
)}
Define a movie edit screen to perform the edits. This simple screen doesn't use our common
scaffold. It fetches the movie (using our new getMovie
function), and doesn't refetch
until/unless a new id is passed in. This movie holds the title and description that's being edited.
Whenever a change is made, we create a copy with the new value and send it out via onMovieChange
so the caller can make a database update.
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieEdit.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieEditUi(
id: String,
fetchMovie: suspend (String) -> MovieDto,
onMovieChange: (MovieDto) -> Unit,
) {
var movie by remember { mutableStateOf<MovieDto?>(null) }
LaunchedEffect(key1 = id) {
withContext(Dispatchers.IO) {
movie = fetchMovie(id)
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text(text = movie?.title ?: "") })
}
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues)
) {
movie?.let { fetchedMovie ->
OutlinedTextField(
value = fetchedMovie.title,
label = { Text(text = stringResource(id = R.string.title))},
onValueChange = {
val newMovie = fetchedMovie.copy(title = it)
movie = newMovie
onMovieChange(newMovie)
},
)
OutlinedTextField(
value = fetchedMovie.description,
label = { Text(text = stringResource(id = R.string.description))},
onValueChange = {
val newMovie = fetchedMovie.copy(description = it)
movie = newMovie
onMovieChange(newMovie)
},
)
}
}
}
}
To finish things up, we set up onEdit
in Ui
to navigate from the display screen to the
edit screen and add the MovieEditScreen
to the when
in Ui
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/Ui.kt
// ...
@Composable
fun Ui(
// ...
) {
// ...
when (val screen = viewModel.currentScreen) {
// ...
is MovieDisplay -> {
// ...
MovieDisplayUi(
// ...
}
},
onEdit = { viewModel.pushScreen(MovieEdit(it)) },
modifier = modifier,
)
}
is MovieEdit -> {
MovieEditUi(
id = screen.id,
fetchMovie = viewModel::getMovie,
onMovieChange = viewModel::update
)
}
is ActorDisplay -> {
val selectedIds by viewModel
// ...
}
}
When we run, we can now edit our movie information!
I'll leave the other screens as exercises for the interested reader.
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.ActorDto
import com.androidbyexample.compose.movies.repository.MovieDatabaseRepository
import com.androidbyexample.compose.movies.repository.MovieDto
import com.androidbyexample.compose.movies.repository.MovieRepository
import com.androidbyexample.compose.movies.repository.RatingDto
import com.androidbyexample.compose.movies.screens.MovieList
import com.androidbyexample.compose.movies.screens.Screen
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
@OptIn(FlowPreview::class)
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 setScreen(screen: Screen) {
screenStack = listOf(screen)
}
fun update(movie: MovieDto) {
movieUpdateFlow.value = movie
}
fun update(actor: ActorDto) {
actorUpdateFlow.value = actor
}
fun update(rating: RatingDto) {
ratingUpdateFlow.value = rating
}
// using a debounced flow as a person-update queue
private val movieUpdateFlow = MutableStateFlow<MovieDto?>(null)
private val actorUpdateFlow = MutableStateFlow<ActorDto?>(null)
private val ratingUpdateFlow = MutableStateFlow<RatingDto?>(null)
init {
viewModelScope.launch {
movieUpdateFlow.debounce(500).collect { movie ->
movie?.let { repository.upsert(it) }
}
}
viewModelScope.launch {
actorUpdateFlow.debounce(500).collect { actor ->
actor?.let { repository.upsert(it) }
}
}
viewModelScope.launch {
ratingUpdateFlow.debounce(500).collect { rating ->
rating?.let { repository.upsert(it) }
}
}
}
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/ListScaffold.kt
package com.androidbyexample.compose.movies.screens
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
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.Edit
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.ScreenSelectButton
import com.androidbyexample.compose.movies.repository.HasId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T: HasId> ListScaffold(
@StringRes titleId: Int,
items: List<T>,
onItemClicked: (T) -> Unit,
selectedIds: Set<String>,
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
onDeleteSelectedItems: () -> Unit,
currentScreen: Screen,
onSelectListScreen: (Screen) -> Unit,
onResetDatabase: () -> Unit,
onEdit: (() -> Unit)? = null,
modifier: Modifier = Modifier,
topContent: (@Composable () -> Unit)? = null,
itemContent: @Composable ColumnScope.(T) -> Unit,
) {
Scaffold(
topBar = {
if (selectedIds.isEmpty()) {
TopAppBar(
title = { Text(text = stringResource(titleId)) },
actions = {
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 = onDeleteSelectedItems) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.delete_selected_items)
)
}
},
)
}
},
bottomBar = {
NavigationBar {
ScreenSelectButton(
targetScreen = RatingList,
imageVector = Icons.Default.Star,
labelId = R.string.ratings,
currentScreen = currentScreen,
onSelectListScreen = onSelectListScreen
)
ScreenSelectButton(
targetScreen = MovieList,
imageVector = Icons.Default.Movie,
labelId = R.string.movies,
currentScreen = currentScreen,
onSelectListScreen = onSelectListScreen
)
ScreenSelectButton(
targetScreen = ActorList,
imageVector = Icons.Default.Person,
labelId = R.string.actors,
currentScreen = currentScreen,
onSelectListScreen = onSelectListScreen
)
}
},
modifier = modifier
) { paddingValues ->
List(
items = items,
onItemClicked = onItemClicked,
selectedIds = selectedIds,
onSelectionToggle = onSelectionToggle,
onClearSelections = onClearSelections,
topContent = topContent,
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
itemContent = itemContent,
)
}
}
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/screens/MovieDisplay.kt
package com.androidbyexample.compose.movies.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material3.Icon
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.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.components.Label
import com.androidbyexample.compose.movies.repository.MovieWithCastDto
import com.androidbyexample.compose.movies.repository.RoleWithActorDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun MovieDisplayUi(
id: String,
fetchMovie: suspend (String) -> MovieWithCastDto,
onActorClicked: (RoleWithActorDto) -> Unit,
selectedIds: Set<String>,
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
onDeleteSelectedMovies: () -> Unit,
currentScreen: Screen,
onSelectListScreen: (Screen) -> Unit,
onResetDatabase: () -> Unit,
onEdit: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var movieWithCast by remember { mutableStateOf<MovieWithCastDto?>(null) }
LaunchedEffect(key1 = id) {
withContext(Dispatchers.IO) {
movieWithCast = fetchMovie(id)
}
}
ListScaffold(
titleId = R.string.movie,
onEdit = movieWithCast?.let {
{ // if we have a movie, pass this lambda as the edit function (else null from let)
onEdit(it.movie.id)
}
},
items = movieWithCast?.cast?.sortedBy { it.orderInCredits } ?: emptyList(),
onItemClicked = onActorClicked,
selectedIds = selectedIds,
onSelectionToggle = onSelectionToggle,
onClearSelections = onClearSelections,
onDeleteSelectedItems = onDeleteSelectedMovies,
currentScreen = currentScreen,
onSelectListScreen = onSelectListScreen,
onResetDatabase = onResetDatabase,
modifier = modifier,
itemContent = { role ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Movie,
contentDescription = stringResource(id = R.string.movie),
modifier = Modifier.clickable {
onSelectionToggle(role.id)
}
)
Display(
text = stringResource(
R.string.cast_entry,
role.character,
role.actor.name,
)
)
}
},
topContent = {
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)
}
)}
ADDED: app/src/main/java/com/androidbyexample/compose/movies/screens/MovieEdit.kt
package com.androidbyexample.compose.movies.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
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.compose.movies.repository.MovieDto
import com.androidbyexample.compose.movies.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieEditUi(
id: String,
fetchMovie: suspend (String) -> MovieDto,
onMovieChange: (MovieDto) -> Unit,
) {
var movie by remember { mutableStateOf<MovieDto?>(null) }
LaunchedEffect(key1 = id) {
withContext(Dispatchers.IO) {
movie = fetchMovie(id)
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text(text = movie?.title ?: "") })
}
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues)
) {
movie?.let { fetchedMovie ->
OutlinedTextField(
value = fetchedMovie.title,
label = { Text(text = stringResource(id = R.string.title))},
onValueChange = {
val newMovie = fetchedMovie.copy(title = it)
movie = newMovie
onMovieChange(newMovie)
},
)
OutlinedTextField(
value = fetchedMovie.description,
label = { Text(text = stringResource(id = R.string.description))},
onValueChange = {
val newMovie = fetchedMovie.copy(description = it)
movie = newMovie
onMovieChange(newMovie)
},
)
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/screens/Screens.kt
package com.androidbyexample.compose.movies.screens
sealed interface Screen
data object MovieList: Screen
data object ActorList: Screen
data object RatingList: Screen
data class MovieDisplay(val id: String): Screen
data class ActorDisplay(val id: String): Screen
data class RatingDisplay(val id: String): Screen
data class MovieEdit(val id: String): 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 -> {
val selectedIds by viewModel
.selectedIdsFlow
.collectAsStateWithLifecycle(initialValue = emptySet())
MovieDisplayUi(
id = screen.id,
fetchMovie = viewModel::getMovieWithCast,
onActorClicked = { viewModel.pushScreen(ActorDisplay(it.actor.id)) }, selectedIds = selectedIds,
onClearSelections = viewModel::clearSelectedIds,
onSelectionToggle = viewModel::toggleSelection,
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
currentScreen = screen,
onSelectListScreen = viewModel::setScreen,
onResetDatabase = {
scope.launch(Dispatchers.IO) {
viewModel.resetDatabase()
}
},
modifier = modifier,
)
}
is MovieEdit -> {
MovieEditUi(
id = screen.id,
fetchMovie = viewModel::getMovie,
onMovieChange = viewModel::update
)
}
is ActorDisplay -> {
val selectedIds by viewModel
.selectedIdsFlow
.collectAsStateWithLifecycle(initialValue = emptySet())
ActorDisplayUi(
id = screen.id,
fetchActor = viewModel::getActorWithFilmography,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it.id)) },
selectedIds = selectedIds,
onClearSelections = viewModel::clearSelectedIds,
onSelectionToggle = viewModel::toggleSelection,
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
currentScreen = screen,
onSelectListScreen = viewModel::setScreen,
onResetDatabase = {
scope.launch(Dispatchers.IO) {
viewModel.resetDatabase()
}
}
)
}
is RatingDisplay -> {
val selectedIds by viewModel
.selectedIdsFlow
.collectAsStateWithLifecycle(initialValue = emptySet())
val ratingWithMovies by viewModel
.getRatingWithMoviesFlow(screen.id)
.collectAsStateWithLifecycle(initialValue = null)
RatingDisplayUi(
ratingWithMovies = ratingWithMovies,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it.id)) },
selectedIds = selectedIds,
onClearSelections = viewModel::clearSelectedIds,
onSelectionToggle = viewModel::toggleSelection,
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
currentScreen = screen,
onSelectListScreen = viewModel::setScreen,
onResetDatabase = {
scope.launch(Dispatchers.IO) {
viewModel.resetDatabase()
}
}
)
}
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,
currentScreen = screen,
onSelectListScreen = viewModel::setScreen,
onResetDatabase = {
scope.launch (Dispatchers.IO) {
viewModel.resetDatabase()
}
},
onMovieClicked = { movie ->
viewModel.pushScreen(MovieDisplay(movie.id))
},
)
}
ActorList -> {
val actors by viewModel.actorsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
val selectedIds by viewModel.selectedIdsFlow.collectAsStateWithLifecycle(initialValue = emptySet())
ActorListUi(
actors = actors,
onActorClicked = { actor ->
viewModel.pushScreen(ActorDisplay(actor.id))
},
selectedIds = selectedIds,
onClearSelections = viewModel::clearSelectedIds,
onSelectionToggle = viewModel::toggleSelection,
onDeleteSelectedActors = viewModel::deleteSelectedActors,
currentScreen = screen,
onSelectListScreen = viewModel::setScreen,
onResetDatabase = {
scope.launch(Dispatchers.IO) {
viewModel.resetDatabase()
}
}
)
}
RatingList -> {
val ratings by viewModel.ratingsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
val selectedIds by viewModel.selectedIdsFlow.collectAsStateWithLifecycle(initialValue = emptySet())
RatingListUi(
ratings = ratings,
onRatingClicked = { rating ->
viewModel.pushScreen(RatingDisplay(rating.id))
},
selectedIds = selectedIds,
onClearSelections = viewModel::clearSelectedIds,
onSelectionToggle = viewModel::toggleSelection,
onDeleteSelectedRatings = viewModel::deleteSelectedRatings,
currentScreen = screen,
onSelectListScreen = viewModel::setScreen,
onResetDatabase = {
scope.launch(Dispatchers.IO) {
viewModel.resetDatabase()
}
}
)
}
}
}
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>
<string name="ratings">Ratings</string>
<string name="rating">Rating</string>
<string name="actors">Actors</string>
<string name="actor">Actor</string>
<string name="name">Name</string>
<string name="movies_rated">Movies rated %1$s</string>
<string name="movies_starring">Movies starring %1$s</string>
<string name="edit">Edit</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 androidx.room.Upsert
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
@Transaction
@Query("SELECT * FROM RatingEntity WHERE id = :id")
abstract fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMovies>
@Query("SELECT * FROM MovieEntity WHERE id = :id")
abstract suspend fun getMovie(id: String): MovieEntity
@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)
@Upsert
abstract suspend fun upsert(vararg ratings: RatingEntity)
@Upsert
abstract suspend fun upsert(vararg movies: MovieEntity)
@Upsert
abstract suspend fun upsert(vararg actors: ActorEntity)
@Upsert
abstract suspend fun upsert(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.Flow
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 fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto> =
dao.getRatingWithMoviesFlow(id)
.map { it.toDto() }
override suspend fun getMovie(id: String): MovieDto =
dao.getMovie(id).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 upsert(movie: MovieDto) = dao.upsert(movie.toEntity())
override suspend fun upsert(actor: ActorDto) = dao.upsert(actor.toEntity())
override suspend fun upsert(rating: RatingDto) = dao.upsert(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>>
fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto>
suspend fun getMovie(id: String): MovieDto
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 upsert(movie: MovieDto)
suspend fun upsert(actor: ActorDto)
suspend fun upsert(rating: RatingDto)
suspend fun deleteMoviesById(ids: Set<String>)
suspend fun deleteActorsById(ids: Set<String>)
suspend fun deleteRatingsById(ids: Set<String>)
suspend fun resetDatabase()
}