Movies UI - Updates
Deleting from the display screens
Our display screens show lists of related data. For example, our MovieDisplayUi shows the cast of
the movie, a list of actors. When we select actors in that list and press delete, we'd like to
delete those actors.
For the movie and actor display screens, the data we're displaying is role information,
and we're using the derived id for these objects in RoleWithMovieDto and
RoleWithActorDto.
We've currently defined RoleWithActor as
show in full file repository/src/main/java/com/androidbyexample/movies/repository/MovieDto.kt
// ...
data class RoleWithActorDto(
val actor: ActorDto,
val character: String,
val orderInCredits: Int,
): HasId {
override val id: String
get() = "${actor.id}:$character"
override val targetId: String
get() = actor.id
}
// ...
We have derived its id from the actor id and character name for uniqueness (though the
orderInCredits may offer better uniqueness, in case the same actor plays two characters in
the movie with the same name...). We've also defined targetId as just the actor's id so we
know where to go. The targetId is actor we want to delete.
We delete by taking the selected ids and passing them to the delete function. This doesn't work
for items that use a derived id like this. We need to use the target id.
But it's a little trickier than that. The same entities could appear multiple times (like the same actor playing multiple roles - I'm looking at you, Tatiana Maslany... (If you haven't watched Orphan Black, do yourself a favor and watch it. Most amazing actress ever!))
We could keep a separate set of targetIds as the items are selected/deselected. If you select
two items that reference the same actor in multple roles, the targetId would get added to that
set twice, resulting in a single occurence in the set (per the definition of a set). If you
unselect one of the items, that targetId is removed and even though the other item with the
same targetId is still selected, it won't appear in the new set and won't be deleted.
So to make this work, we'll need to create a set at the very last minute. The only safe way to do it with our current data structures is to map the set of selected item ids back to target ids at the point of deletion.
show in full file app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
// ...
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <T: HasId> ListScaffold(
// ...
) {
// ...
Scaffold(
topBar = {
// ...
if (selectedIds.isEmpty()) {
// ...
} else {
TopAppBar(
// ...
actions = {
IconButton(
onClick = {
// onDeleteSelectedItems(selectedIds.toSet())
// // NOTE - this is a mutable set, and we need to be sure we just
// // pass a read-only copy out of here. If we don't, the
// // caller could modify the contents AND we set up a race
// // condition between the clear() call below and the read
// // of the data in the caller.
// We might be tracking derived ids in the set. We need to
// delete the targetIds instead. We'll map the selectedIds
// to targetIds and send those to be deleted.
val targetIds =
selectedIds.mapNotNull { id ->
items.find { it.id == id }?.targetId
}.toSet()
onDeleteSelectedItems(targetIds)
selectedIds.clear()
}
) {
Icon(
painter = painterResource(R.drawable.delete_24),
contentDescription = stringResource(R.string.delete_selected_items)
)
}
},
)
}
},
// ...
) { innerPadding ->
// ...
}
}
Then we can hook up the deletion calls in Ui
onDeleteSelectedActors = viewModel::deleteSelectedActors
To test deletion, we can go to a MovieDisplay and try to delete an actor:
- Go to the movies list
- Select "The Transporter"
- Click on the icon next to "Frank Martin: Jason Statham"
- Click the trash can on the top bar to delete the actor
And... nothing happens! Or at least nothing seems to happen.

- But if we click on the movies list
- Then go back into "The Transporter"
We see that "Frank Martin: Jason Statham" has indeed been deleted.
What's happening?
Once again, it's all down to where the state is being set/read.
- When we select "The Transporter", we pass the id of the movie entity into
MovieDisplayUi MovieDisplayUifetches aMovieWithCastDtousing that id inside aLaunchedEffect- When we delete an actor from the database, the only thing that changes is the data in the
ActorEntitytable, emitting a newActorListto theFlowfor all actors. - We're not collecting that
Flowfor this screen; nothing triggers a refetch of the currently-displayed movie (the movie id hasn't changed) - We're stuck with the previously-fetched
MovieWithCastDto.
How do we fix this? We need something to tell us that the movie has changed.
Pass in the MovieWithCastDto instead of fetching it (and get rid of the fetcher function
and LaunchedEffect that loaded the movie)
show in full file app/src/main/java/com/androidbyexample/movies/screens/MovieDisplayUi.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieDisplayUi(
// id: String,
// fetchMovie: suspend (String) -> MovieWithCastDto,
movieWithCast: MovieWithCastDto?,
onActorClicked: (String) -> Unit,
onDeleteSelectedActors: (Set<String>) -> Unit,
modifier: Modifier = Modifier,
) {
// var movieWithCast by remember { mutableStateOf<MovieWithCastDto?>(null) }
// LaunchedEffect(key1 = id) {
// withContext(Dispatchers.IO) {
// movieWithCast = fetchMovie(id)
// }
// }
//
movieWithCast?.let { movieWithCast ->
// ...
}
Create DAO functions to get the compound objects (like movie with cast) using a Flow
show in full file data/src/main/java/com/androidbyexample/movies/data/MovieDao.kt
// ...
@Dao
abstract class MovieDao {
// ...
abstract fun getActorsFlow(): Flow<List<ActorEntity>>
@Transaction
@Query("SELECT * FROM RatingEntity WHERE id = :id")
abstract fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMovies>
@Transaction
@Query("SELECT * FROM ActorEntity WHERE id = :id")
abstract fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmography>
@Transaction
@Query("SELECT * FROM MovieEntity WHERE id = :id")
abstract fun getMovieWithCastFlow(id: String): Flow<MovieWithCast>
@Transaction
// ...
}
Note
These are similar to the existing one-shot suspend functions that directly retrieved the
data. The new ones have new names (I added "Flow" to end the of each), are not suspend
functions, and return Flows instead of the actual data.
When called, these functions will immediately return a flow. They'll then process the request asynchronously, and when the data is available, drop it in the flow for the caller to collect.
We need to forward these function through the repository, database repository implementation, and view model (automatically done via delegation)
show in full file repository/src/main/java/com/androidbyexample/movies/repository/MovieRepository.kt
// ...
interface MovieRepository {
// ...
val moviesFlow: Flow<List<MovieDto>>
val actorsFlow: Flow<List<ActorDto>>
fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto>
fun getMovieWithCastFlow(id: String): Flow<MovieWithCastDto>
fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmographyDto>
suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto
suspend fun getMovieWithCast(id: String): MovieWithCastDto
// ...
}
show in full file repository/src/main/java/com/androidbyexample/movies/repository/MovieDatabaseRepository.kt
// ...
class MovieDatabaseRepository(
// ...
): MovieRepository {
// ...
actors.map { it.toDto() }
}
override fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto> =
dao.getRatingWithMoviesFlow(id).map { it.toDto() }
override fun getMovieWithCastFlow(id: String): Flow<MovieWithCastDto> =
dao.getMovieWithCastFlow(id).map { it.toDto() }
override fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmographyDto> =
dao.getActorWithFilmographyFlow(id).map { it.toDto() }
override suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto =
dao.getRatingWithMovies(id).toDto()
// ...
}
Note the map calls in the MovieDatabaseRepository. These create a new Flow that wraps the
real flow to transform its data. When a new value is passed in the Flow, the toDto() function
will be called on it before it's collected by the caller.
Collect the Flow and pass the result into the rating display
show in full file app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
// ...
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
// ...
) {
// ...
NavigationSuiteScaffold(
// ...
) {
NavDisplay(
// ...
entryProvider = entryProvider {
// ...
entry<MovieDisplay>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
val movieWithCast by
viewModel
.getMovieWithCastFlow(key.id)
.collectAsStateWithLifecycle(null)
MovieDisplayUi(
// id = key.id,
// fetchMovie = viewModel::getMovieWithCast,
movieWithCast = movieWithCast,
onActorClicked = { viewModel.pushScreen(ActorDisplay(it)) },
// onDeleteSelectedActors = { },
onDeleteSelectedActors = viewModel::deleteSelectedActors,
)
}
// ...
}
)
}
}
Poof! It works now!
Do the same for the rating and actor displays:
show in full file app/src/main/java/com/androidbyexample/movies/screens/ActorDisplayUi.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActorDisplayUi(
// id: String,
// fetchActor: suspend (String) -> ActorWithFilmographyDto,
actorWithFilmography: ActorWithFilmographyDto?,
onMovieClicked: (String) -> Unit,
onDeleteSelectedMovies: (Set<String>) -> Unit,
modifier: Modifier = Modifier,
) {
// var actorWithFilmography by remember { mutableStateOf<ActorWithFilmographyDto?>(null) }
// LaunchedEffect(key1 = id) {
// withContext(Dispatchers.IO) {
// actorWithFilmography = fetchActor(id)
// }
// }
//
actorWithFilmography?.let { actorWithFilmography ->
// ...
}
show in full file app/src/main/java/com/androidbyexample/movies/screens/RatingDisplayUi.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RatingDisplayUi(
// id: String,
// fetchRating: suspend (String) -> RatingWithMoviesDto,
ratingWithMovies: RatingWithMoviesDto?,
onMovieClicked: (String) -> Unit,
onDeleteSelectedMovies: (Set<String>) -> Unit,
modifier: Modifier = Modifier,
) {
// var ratingWithMovies by remember { mutableStateOf<RatingWithMoviesDto?>(null) }
// LaunchedEffect(key1 = id) {
// withContext(Dispatchers.IO) {
// ratingWithMovies = fetchRating(id)
// }
// }
//
ratingWithMovies?.let { ratingWithMovies ->
// ...
}
show in full file app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
// ...
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
// ...
) {
// ...
NavigationSuiteScaffold(
// ...
) {
NavDisplay(
// ...
entryProvider = entryProvider {
// ...
entry<ActorDisplay>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
val actorWithFilmography by
viewModel
.getActorWithFilmographyFlow(key.id)
.collectAsStateWithLifecycle(null)
ActorDisplayUi(
// id = key.id,
// fetchActor = viewModel::getActorWithFilmography,
actorWithFilmography = actorWithFilmography,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) },
// onDeleteSelectedMovies = { },
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
)
}
// ...
entry<RatingDisplay>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
val ratingWithMovies by
viewModel
.getRatingWithMoviesFlow(key.id)
.collectAsStateWithLifecycle(null)
RatingDisplayUi(
// id = key.id,
// fetchRating = viewModel::getRatingWithMovies,
ratingWithMovies = ratingWithMovies,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) },
// onDeleteSelectedMovies = { },
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
)
}
}
)
}
}
The trick here was using data that's automatically updated, sending us a new MovieWithCastDto
when an actor is deleted. We collect that flow in Ui, updating the value that we pass into the
movie display, triggering recomposition.
!
All code changes
CHANGED: app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
package com.androidbyexample.movies.helper
import androidx.activity.compose.BackHandler
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.TopAppBarDefaults
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.androidbyexample.movies.R
import com.androidbyexample.movies.repository.HasId
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <T: HasId> ListScaffold(
title: String,
items: List<T>,
onItemClicked: (String) -> Unit,
onDeleteSelectedItems: (Set<String>) -> Unit,
onResetDatabase: (() -> Unit)?,
@DrawableRes itemIconId: Int,
@StringRes itemContentDescriptionId: Int,
modifier: Modifier = Modifier,
fixedContentAbove: @Composable () -> Unit = {},
cardContent: @Composable (T) -> Unit,
) {
val selectedIds = rememberSaveable { mutableStateSetOf<String>()}
fun onSelectionToggle(id: String) {
if (id in selectedIds) {
selectedIds -= id
} else {
selectedIds += id
}
}
fun clearSelectedIds() {
selectedIds.clear()
}
if (selectedIds.isNotEmpty()) {
BackHandler {
clearSelectedIds()
}
}
Scaffold(
topBar = {
val colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onSecondary
)
if (selectedIds.isEmpty()) {
TopAppBar(
colors = colors,
title = {
Text(text = title)
},
actions = {
onResetDatabase?.let {
IconButton(onClick = onResetDatabase) {
Icon(
painter = painterResource(R.drawable.refresh_24),
contentDescription = stringResource(R.string.reset_database),
)
}
}
}
)
} else {
TopAppBar(
colors = colors,
navigationIcon = {
Icon(
painter = painterResource(R.drawable.arrow_back_24),
contentDescription = stringResource(R.string.clear_selections),
modifier = Modifier.clickable(onClick = ::clearSelectedIds),
)
},
title = {
Text(
text = selectedIds.size.toString(),
modifier = Modifier.padding(8.dp)
)
},
actions = {
IconButton(
onClick = {
// onDeleteSelectedItems(selectedIds.toSet())
// // NOTE - this is a mutable set, and we need to be sure we just
// // pass a read-only copy out of here. If we don't, the
// // caller could modify the contents AND we set up a race
// // condition between the clear() call below and the read
// // of the data in the caller.
// We might be tracking derived ids in the set. We need to
// delete the targetIds instead. We'll map the selectedIds
// to targetIds and send those to be deleted.
val targetIds =
selectedIds.mapNotNull { id ->
items.find { it.id == id }?.targetId
}.toSet()
onDeleteSelectedItems(targetIds)
selectedIds.clear()
}
) {
Icon(
painter = painterResource(R.drawable.delete_24),
contentDescription = stringResource(R.string.delete_selected_items)
)
}
},
)
}
},
modifier = modifier,
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding)
) {
item {
fixedContentAbove()
}
items(
items = items,
key = { it.id },
) { item ->
val containerColor =
if (item.id in selectedIds) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
}
val contentColor = MaterialTheme.colorScheme.contentColorFor(containerColor)
Card(
elevation = CardDefaults.cardElevation(
defaultElevation = 8.dp,
),
colors = CardDefaults.cardColors(
containerColor = containerColor,
contentColor = contentColor,
),
modifier = Modifier
.padding(8.dp)
.animateItem()
.combinedClickable(
// NOTE - use targetId for navigation, id for selections
onClick = {
if (selectedIds.isEmpty()) {
onItemClicked(item.targetId)
} else {
onSelectionToggle(item.id)
}
},
onLongClick = {
onSelectionToggle(item.id)
},
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp),
) {
Icon(
painter = painterResource(itemIconId),
contentDescription = stringResource(itemContentDescriptionId),
modifier = Modifier.clickable {
onSelectionToggle(item.id)
}
)
cardContent(item)
}
}
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/ActorDisplayUi.kt
package com.androidbyexample.movies.screens
import androidx.compose.material3.ExperimentalMaterial3Api
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.movies.R
import com.androidbyexample.movies.helper.Display
import com.androidbyexample.movies.helper.Label
import com.androidbyexample.movies.helper.ListScaffold
import com.androidbyexample.movies.repository.ActorWithFilmographyDto
//import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActorDisplayUi(
// id: String,
// fetchActor: suspend (String) -> ActorWithFilmographyDto,
actorWithFilmography: ActorWithFilmographyDto?,
onMovieClicked: (String) -> Unit,
onDeleteSelectedMovies: (Set<String>) -> Unit,
modifier: Modifier = Modifier,
) {
// var actorWithFilmography by remember { mutableStateOf<ActorWithFilmographyDto?>(null) }
// LaunchedEffect(key1 = id) {
// withContext(Dispatchers.IO) {
// actorWithFilmography = fetchActor(id)
// }
// }
//
actorWithFilmography?.let { actorWithFilmography ->
ListScaffold(
title = actorWithFilmography.actor.name,
items = actorWithFilmography.filmography,
onItemClicked = onMovieClicked,
onDeleteSelectedItems = onDeleteSelectedMovies,
onResetDatabase = null,
itemIconId = R.drawable.movie_24,
itemContentDescriptionId = R.string.movie,
modifier = modifier,
fixedContentAbove = {
Label(textId = R.string.title)
Display(text = actorWithFilmography.actor.name)
Label(
textId = R.string.movies_starring,
actorWithFilmography.actor.name
)
},
cardContent = { role ->
Display(
text = stringResource(
R.string.cast_entry,
role.character,
role.movie.title,
)
)
},
)
}
}
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/MovieDisplayUi.kt
package com.androidbyexample.movies.screens
import androidx.compose.material3.ExperimentalMaterial3Api
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.movies.R
import com.androidbyexample.movies.helper.Display
import com.androidbyexample.movies.helper.Label
import com.androidbyexample.movies.helper.ListScaffold
import com.androidbyexample.movies.repository.MovieWithCastDto
//import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieDisplayUi(
// id: String,
// fetchMovie: suspend (String) -> MovieWithCastDto,
movieWithCast: MovieWithCastDto?,
onActorClicked: (String) -> Unit,
onDeleteSelectedActors: (Set<String>) -> Unit,
modifier: Modifier = Modifier,
) {
// var movieWithCast by remember { mutableStateOf<MovieWithCastDto?>(null) }
// LaunchedEffect(key1 = id) {
// withContext(Dispatchers.IO) {
// movieWithCast = fetchMovie(id)
// }
// }
//
movieWithCast?.let { movieWithCast ->
ListScaffold(
title = movieWithCast.movie.title,
items = movieWithCast.cast,
onItemClicked = onActorClicked,
onDeleteSelectedItems = onDeleteSelectedActors,
onResetDatabase = null,
itemIconId = R.drawable.movie_24,
itemContentDescriptionId = R.string.movie,
modifier = modifier,
fixedContentAbove = {
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)
},
cardContent = { role ->
Display(
text = stringResource(
R.string.cast_entry,
role.character,
role.actor.name,
)
)
},
)
} ?: run {
DetailPlaceholder(messageId = R.string.loading)
}
}
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/RatingDisplayUi.kt
package com.androidbyexample.movies.screens
import androidx.compose.material3.ExperimentalMaterial3Api
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 com.androidbyexample.movies.R
import com.androidbyexample.movies.helper.Display
import com.androidbyexample.movies.helper.Label
import com.androidbyexample.movies.helper.ListScaffold
import com.androidbyexample.movies.repository.RatingWithMoviesDto
//import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RatingDisplayUi(
// id: String,
// fetchRating: suspend (String) -> RatingWithMoviesDto,
ratingWithMovies: RatingWithMoviesDto?,
onMovieClicked: (String) -> Unit,
onDeleteSelectedMovies: (Set<String>) -> Unit,
modifier: Modifier = Modifier,
) {
// var ratingWithMovies by remember { mutableStateOf<RatingWithMoviesDto?>(null) }
// LaunchedEffect(key1 = id) {
// withContext(Dispatchers.IO) {
// ratingWithMovies = fetchRating(id)
// }
// }
//
ratingWithMovies?.let { ratingWithMovies ->
ListScaffold(
title = ratingWithMovies.rating.name,
items = ratingWithMovies.movies,
onItemClicked = onMovieClicked,
onDeleteSelectedItems = onDeleteSelectedMovies,
onResetDatabase = null,
itemIconId = R.drawable.movie_24,
itemContentDescriptionId = R.string.movie,
modifier = modifier,
fixedContentAbove = {
Label(textId = R.string.name)
Display(text = ratingWithMovies.rating.name)
Label(textId = R.string.description)
Display(text = ratingWithMovies.rating.description)
Label(
textId = R.string.movies_rated,
ratingWithMovies.rating.name
)
},
cardContent = { movie ->
Display(
text = movie.title
)
},
)
}
}
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
package com.androidbyexample.movies.screens
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.androidbyexample.movies.MovieViewModel
import com.androidbyexample.movies.R
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
viewModel: MovieViewModel,
) {
val windowAdaptiveInfo = currentWindowAdaptiveInfoV2()
val directive = remember(windowAdaptiveInfo) {
calculatePaneScaffoldDirective(windowAdaptiveInfo)
.copy(horizontalPartitionSpacerSize = 0.dp)
}
val listDetailStrategy = rememberListDetailSceneStrategy<Screen>(directive = directive)
val backStack by viewModel.backStackFlow.collectAsStateWithLifecycle(listOf(MovieList))
val currentListScreen by viewModel.currentListScreenFlow.collectAsStateWithLifecycle(MovieList)
val listScreens = remember { listOf(RatingList, MovieList, ActorList) }
NavigationSuiteScaffold(
navigationSuiteItems = {
listScreens.forEach { target ->
item(
icon = {
Icon(
painter = painterResource(target.iconId),
contentDescription = stringResource(target.labelId)
)
},
label = { Text(stringResource(target.labelId)) },
selected = currentListScreen == target,
onClick = {
viewModel.goToListScreen(target)
}
)
}
}
) {
NavDisplay(
backStack = backStack,
onBack = viewModel::popScreen,
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider {
entry<MovieList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { DetailPlaceholder(R.string.select_a_movie_to_view) }
)
) {
val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
MovieListUi(
movies = movies,
onMovieClicked = { movieId ->
viewModel.pushScreen(MovieDisplay(movieId))
},
onDeleteSelectedMovies = { ids ->
viewModel.deleteSelectedMovies(ids)
},
onResetDatabase = viewModel::doResetDatabase,
)
}
entry<MovieDisplay>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
val movieWithCast by
viewModel
.getMovieWithCastFlow(key.id)
.collectAsStateWithLifecycle(null)
MovieDisplayUi(
// id = key.id,
// fetchMovie = viewModel::getMovieWithCast,
movieWithCast = movieWithCast,
onActorClicked = { viewModel.pushScreen(ActorDisplay(it)) },
// onDeleteSelectedActors = { },
onDeleteSelectedActors = viewModel::deleteSelectedActors,
)
}
entry<ActorList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { DetailPlaceholder(R.string.select_an_actor_to_view) }
)
) {
val actors by viewModel.actorsFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
ActorListUi(
actors = actors,
onActorClicked = { actorId ->
viewModel.pushScreen(ActorDisplay(actorId))
},
onDeleteSelectedActors = { ids ->
viewModel.deleteSelectedActors(ids)
},
onResetDatabase = viewModel::doResetDatabase,
)
}
entry<ActorDisplay>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
val actorWithFilmography by
viewModel
.getActorWithFilmographyFlow(key.id)
.collectAsStateWithLifecycle(null)
ActorDisplayUi(
// id = key.id,
// fetchActor = viewModel::getActorWithFilmography,
actorWithFilmography = actorWithFilmography,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) },
// onDeleteSelectedMovies = { },
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
)
}
entry< RatingList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { DetailPlaceholder(R.string.select_a_rating_to_view) }
)
) {
val ratings by viewModel.ratingsFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
RatingListUi(
ratings = ratings,
onRatingClicked = { ratingId ->
viewModel.pushScreen(RatingDisplay(ratingId))
},
onDeleteSelectedRatings = { ids ->
viewModel.deleteSelectedRatings(ids)
},
onResetDatabase = viewModel::doResetDatabase,
)
}
entry<RatingDisplay>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
val ratingWithMovies by
viewModel
.getRatingWithMoviesFlow(key.id)
.collectAsStateWithLifecycle(null)
RatingDisplayUi(
// id = key.id,
// fetchRating = viewModel::getRatingWithMovies,
ratingWithMovies = ratingWithMovies,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) },
// onDeleteSelectedMovies = { },
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
)
}
}
)
}
}
CHANGED: data/src/main/java/com/androidbyexample/movies/data/MovieDao.kt
package com.androidbyexample.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 fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMovies>
@Transaction
@Query("SELECT * FROM ActorEntity WHERE id = :id")
abstract fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmography>
@Transaction
@Query("SELECT * FROM MovieEntity WHERE id = :id")
abstract fun getMovieWithCastFlow(id: String): Flow<MovieWithCast>
@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/movies/repository/MovieDatabaseRepository.kt
package com.androidbyexample.movies.repository
import android.content.Context
import com.androidbyexample.movies.data.MovieDao
import com.androidbyexample.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 fun getMovieWithCastFlow(id: String): Flow<MovieWithCastDto> =
dao.getMovieWithCastFlow(id).map { it.toDto() }
override fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmographyDto> =
dao.getActorWithFilmographyFlow(id).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/movies/repository/MovieDto.kt
package com.androidbyexample.movies.repository
import com.androidbyexample.movies.data.MovieEntity
import com.androidbyexample.movies.data.MovieWithCast
import com.androidbyexample.movies.data.RoleWithActor
data class MovieDto(
override val id: String,
val title: String,
val description: String,
val ratingId: String,
): HasId
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>,
)
data class RoleWithActorDto(
val actor: ActorDto,
val character: String,
val orderInCredits: Int,
): HasId {
override val id: String
get() = "${actor.id}:$character"
override val targetId: String
get() = actor.id
}
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()
}
)
CHANGED: repository/src/main/java/com/androidbyexample/movies/repository/MovieRepository.kt
package com.androidbyexample.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>
fun getMovieWithCastFlow(id: String): Flow<MovieWithCastDto>
fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmographyDto>
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()
}