Movies UI - Lists
Refactor time!
Our app bakes the list support in with the Movie UI. We can make it much more reusable!
We're going to have two types of lists in the application:
- Top-level lists of all movies, actors and ratings
- Nested lists, such as actors starring in a movie (on the movie's display screen)
Generic List Data
To create consistent list support, we need to separate the LazyColumn and Scaffold
from the MovieListUi. We want to keep all selection management, but make it more generic.
But there's a problem. If we make a generic List composable, something like
fun <T> ListScaffold(
items: List<T>,
...
) {
...
}
we have several spots that access the item's id. When the item type was explicitly MovieDto,
we knew it had an id, but if the item type is a generic parameter T, we can no longer make
that assumption.
To fix this, we'll create a HasId interface in the repository module. But before we can
create that, we need to create the concept of a common id:
show in full file repository/src/main/java/com/androidbyexample/movies/repository/Id.kt
package com.androidbyexample.movies.repository
interface Id {
val value: String
}
All ids have a value string, so as long as we know we have an Id, we can get the raw
string that represents it.
So we update our id classes to implement the new Id interface.
show in full file repository/src/main/java/com/androidbyexample/movies/repository/MovieId.kt
// ...
import kotlinx.parcelize.Parcelize
//@JvmInline @Parcelize value class MovieId(val value: String): Parcelable
@JvmInline @Parcelize value class MovieId(override val value: String): Id, Parcelable
show in full file repository/src/main/java/com/androidbyexample/movies/repository/ActorId.kt
// ...
import kotlinx.parcelize.Parcelize
//@JvmInline @Parcelize value class ActorId(val value: String): Parcelable
@JvmInline @Parcelize value class ActorId(override val value: String): Id, Parcelable
show in full file repository/src/main/java/com/androidbyexample/movies/repository/RatingId.kt
// ...
import kotlinx.parcelize.Parcelize
//@JvmInline @Parcelize value class RatingId(val value: String): Parcelable
@JvmInline @Parcelize value class RatingId(override val value: String): Id, Parcelable
Setting up our entities with a HasId interface should be simple, but what happens when (later)
we display a cast list using RoleWithActorDto? We need unique ids for each of these as well.
If we have
data class RoleWithActorDto(
val actor: ActorDto,
val character: String,
val orderInCredits: Int,
)
We can generate a unique id by appending the orderInCredits to the actor's id. We can do this
by defining a derived property for the id. A derived property is one whose value is
generated from other properties rather than using a backing field to store data.
Because this is a new type of id, we also need to define a type for it.
@JvmInline
@Parcelize
value class RoleWithActorId(override val value: String) : Id, Parcelable
data class RoleWithActorDto(
val actor: ActorDto,
val character: String,
val orderInCredits: Int,
) : HasId {
override val id: RoleWithActorId
get() = RoleWithActorId("${actor.id}:$orderInCredits")
}
But this will raise another issue further down the line... When we display the cast list, what
happens when the user clicks on one of those RoleWithActorDtos? The way we've written our
list will use the id of the item as the "target" we want to visit when the user clicks. This
won't work with this derived id property, as we really want to go to the actor page.
So we really need a separate property to track the targetId, the id of the item we want to go
to. Often this will have the same value as the id, so we can define a default implementation
in the HasId interface.
interface HasId {
val id: Id
val targetId: Id
get() = id
}
The Ids may, or may not be of the same actual type, and it would be really useful for the users
to know which types they actually are so we can enforce type safety. So we can apply some
generics here for our final implementation of HasId:
show in full file repository/src/main/java/com/androidbyexample/movies/repository/HasId.kt
package com.androidbyexample.movies.repository
interface HasId<ID: Id, TARGET_ID: Id> {
val id: ID
@Suppress("UNCHECKED_CAST")
val targetId: TARGET_ID
get() = id as TARGET_ID
}
When a class indicates that it implements HasId, it must specify two type parameters: ID and
TARGET_ID.
Note
Type parameters are usually specified as a single, upper-case letter. I often prefer to use full names so it's easier to see how they're used, especially if the class or interface is long.
The ID type indicates what type identifies the object that implements HasId. The TARGET_ID
type indicates the type of the thing we should navigate to (or delete). They may be the same
type. Our default implementation of the targetId property returns the id, so we assume
the types are the same. Note that because there's no relation specified between ID and
TARGET_ID, we must use an unchecked cast (and I suppress the warning).
Now we can piece things together...
The ids defined by our entities are the actual unique IDs in the database. These are simple to define:
show in full file repository/src/main/java/com/androidbyexample/movies/repository/MovieDto.kt
// ...
data class MovieDto(
// val id: MovieId,
override val id: MovieId,
val title: String,
val description: String,
val ratingId: String,
//)
): HasId<MovieId, MovieId>
internal fun MovieEntityId.toDtoId() = MovieId(value)
// ...
show in full file repository/src/main/java/com/androidbyexample/movies/repository/ActorDto.kt
// ...
data class ActorDto(
// val id: ActorId,
override val id: ActorId,
val name: String,
//)
): HasId<ActorId, ActorId>
internal fun ActorEntityId.toDtoId() = ActorId(value)
// ...
show in full file repository/src/main/java/com/androidbyexample/movies/repository/RatingDto.kt
// ...
data class RatingDto(
// val id: RatingId,
override val id: RatingId,
val name: String,
val description: String,
//)
): HasId<RatingId, RatingId>
internal fun RatingEntityId.toDtoId() = RatingId(value)
// ...
When each of these is used in a list, they will be the actual target, so we don't need to override
the targetId property.
The compound POKO types must explicitly specify their id and target id types, and override
the targetId property. We also need to define a unique Id type for them:
show in full file repository/src/main/java/com/androidbyexample/movies/repository/MovieDto.kt
// ...
)
@JvmInline @Parcelize value class RoleWithActorId(override val value: String): Id, Parcelable
data class RoleWithActorDto(
val actor: ActorDto,
val character: String,
val orderInCredits: Int,
//)
): HasId<RoleWithActorId, ActorId> {
override val id: RoleWithActorId
get() = RoleWithActorId("${actor.id}:$character")
override val targetId: ActorId
get() = actor.id
}
internal fun RoleWithActor.toDto() =
// ...
show in full file repository/src/main/java/com/androidbyexample/movies/repository/ActorDto.kt
// ...
)
@JvmInline @Parcelize value class RoleWithMovieId(override val value: String): Id, Parcelable
data class RoleWithMovieDto(
val movie: MovieDto,
val character: String,
val orderInCredits: Int,
//)
): HasId<RoleWithMovieId, MovieId> {
override val id: RoleWithMovieId
get() = RoleWithMovieId("${movie.id}:$orderInCredits")
override val targetId: MovieId
get() = movie.id
}
internal fun RoleWithMovie.toDto() =
// ...
Factoring out the list
We can now create a generic ListScaffold composable.
show in full file app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
// ...
import com.androidbyexample.movies.repository.Id
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <ID: Id, TARGET_ID: Id, T: HasId<ID, TARGET_ID>> ListScaffold(
title: String,
items: List<T>,
onItemClicked: (TARGET_ID) -> Unit,
onDeleteSelectedItems: (Set<TARGET_ID>) -> Unit,
onResetDatabase: () -> Unit,
@DrawableRes itemIconId: Int,
@StringRes itemContentDescriptionId: Int,
modifier: Modifier = Modifier,
cardContent: @Composable (T) -> Unit,
) {
val selectedIds = rememberSaveable { mutableStateSetOf<ID>()}
fun onSelectionToggle(id: ID) {
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 = {
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 = {
val targetIds =
selectedIds
.asSequence()
.map { selectedId ->
items.find { it.id == selectedId }?.targetId
?: throw IllegalStateException("selected item state out of sync")
}
.toSet()
selectedIds.clear()
onDeleteSelectedItems(targetIds)
}
) {
Icon(
painter = painterResource(R.drawable.delete_24),
contentDescription = stringResource(R.string.delete_selected_items)
)
}
},
)
}
},
modifier = modifier,
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding)
) {
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)
.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)
}
}
}
}
}
}
There are several spots to call out here to make this generic across all screens that might use it.
First let's take a look at the parameters passed to ListScaffold. This function takes a
generic parameter T that implements HasId:
show in full file app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
// ...
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <ID: Id, TARGET_ID: Id, T: HasId<ID, TARGET_ID>> ListScaffold(
title: String,
items: List<T>,
onItemClicked: (TARGET_ID) -> Unit,
onDeleteSelectedItems: (Set<TARGET_ID>) -> Unit,
onResetDatabase: () -> Unit,
@DrawableRes itemIconId: Int,
@StringRes itemContentDescriptionId: Int,
modifier: Modifier = Modifier,
cardContent: @Composable (T) -> Unit,
) {
val selectedIds = rememberSaveable { mutableStateSetOf<ID>()}
// ...
}
| Parameter | Description |
|---|---|
title |
The text to display at the top of the screen |
items |
The items to display in the list. Items are of generic type T |
onItemClicked |
We change onMovieClicked to be more generic |
onDeleteSelectedItems |
Similarly renamed to be generic |
onResetDatabase |
Same function as before |
itemIconId |
The id of the icon to display in each card |
itemContentDescriptionId |
The content description of that icon |
Modifier |
The normal modifier being passed in |
cardContent |
A function that is called to emit the contents of each card. This function is passed each item in the list. |
When we're dealing with user clicks, we use the HasId functions in each item to obtain the id
and targetId. We use targetId for navigation, and id for unique ids in the LazyColumn and
selection tracking.
show in full file app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
// ...
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <ID: Id, TARGET_ID: Id, T: HasId<ID, TARGET_ID>> ListScaffold(
// ...
) {
// ...
Scaffold(
// ...
) { innerPadding ->
LazyColumn(
// ...
) {
items(
// ...
) { item ->
// ...
Card(
// ...
modifier = Modifier
.padding(8.dp)
.combinedClickable(
// NOTE - use targetId for navigation, id for selections
onClick = {
if (selectedIds.isEmpty()) {
onItemClicked(item.targetId)
} else {
onSelectionToggle(item.id)
}
},
onLongClick = {
onSelectionToggle(item.id)
},
)
) {
// ...
}
}
}
}
}
(We use the id when clicking the card icon as well)
show in full file app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
// ...
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <ID: Id, TARGET_ID: Id, T: HasId<ID, TARGET_ID>> ListScaffold(
// ...
) {
// ...
Scaffold(
// ...
) { innerPadding ->
LazyColumn(
// ...
) {
items(
// ...
) { item ->
// ...
Card(
// ...
) {
Row(
// ...
modifier = Modifier.padding(8.dp),
) {
Icon(
painter = painterResource(itemIconId),
contentDescription = stringResource(itemContentDescriptionId),
modifier = Modifier.clickable {
onSelectionToggle(item.id)
}
)
cardContent(item)
}
}
}
}
}
}
Deletion gets a little more interesting. We use selectedIds to track the ID type of the
items in the list. For some items, such as movies, we could directly ask to delete those
items. For other items, such as a RoleWithMovieDto, we want to delete the contained
movie, its targetId. So we map the selectedIds to the target ids.
Note
The way we're doing it here is a little inefficient; we could set up a secondary set of
selectedTargetIds, but that would be problematic. An actor could play multiple roles in
a movie!
show in full file app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
// ...
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <ID: Id, TARGET_ID: Id, T: HasId<ID, TARGET_ID>> ListScaffold(
// ...
) {
// ...
Scaffold(
topBar = {
// ...
if (selectedIds.isEmpty()) {
// ...
} else {
TopAppBar(
// ...
actions = {
IconButton(
onClick = {
val targetIds =
selectedIds
.asSequence()
.map { selectedId ->
items.find { it.id == selectedId }?.targetId
?: throw IllegalStateException("selected item state out of sync")
}
.toSet()
selectedIds.clear()
onDeleteSelectedItems(targetIds)
}
) {
// ...
}
},
)
}
},
// ...
) { innerPadding ->
// ...
}
}
The card content function is called to fill in the type-specific details.
show in full file app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
// ...
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <ID: Id, TARGET_ID: Id, T: HasId<ID, TARGET_ID>> ListScaffold(
// ...
) {
// ...
Scaffold(
// ...
) { innerPadding ->
LazyColumn(
// ...
) {
items(
// ...
) { item ->
// ...
Card(
// ...
) {
Row(
// ...
) {
// ...
}
)
cardContent(item)
}
}
}
}
}
}
Using the ListScaffold in MovieListUi
We can now replace the common list function in MovieListUi
show in full file app/src/main/java/com/androidbyexample/movies/screens/MovieListUi.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieListUi(
// ...
onResetDatabase: () -> Unit,
) {
// val selectedIds = rememberSaveable { mutableStateSetOf<MovieId>()}
//
// fun onSelectionToggle(id: MovieId) {
// 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 = stringResource(R.string.movies))
// },
// actions = {
// 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 = {
// onDeleteSelectedMovies(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.
// selectedIds.clear()
// }
// ) {
// Icon(
// painter = painterResource(R.drawable.delete_24),
// contentDescription = stringResource(R.string.delete_selected_items)
// )
// }
// },
// )
// }
// },
// modifier = modifier,
// ) { innerPadding ->
// LazyColumn(
// modifier = modifier
// .padding(innerPadding)
// ) {
// items(
ListScaffold(
title = stringResource(R.string.movies),
items = movies,
// key = { it.id }
onItemClicked = onMovieClicked,
onDeleteSelectedItems = onDeleteSelectedMovies,
onResetDatabase = onResetDatabase,
itemIconId = R.drawable.movie_24,
itemContentDescriptionId = R.string.movie,
modifier = modifier
) { movie ->
// val containerColor =
// if (movie.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,
// ),
// border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
// 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(
// painter = painterResource(R.drawable.movie_24),
// contentDescription = stringResource(R.string.movie),
// modifier = Modifier.clickable {
// onSelectionToggle(movie.id)
// }
// )
Display(text = movie.title)
}
// }
// }
// }
// }
}
Note that we change the parameter passed to onMovieClicked to be just the id rather than the
entire object.
show in full file app/src/main/java/com/androidbyexample/movies/screens/MovieListUi.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieListUi(
movies: List<MovieDto>,
modifier: Modifier = Modifier,
// onMovieClicked: (MovieDto) -> Unit,
// onDeleteSelectedMovies: (Set<MovieId>) -> Unit,
onMovieClicked: (MovieId) -> Unit,
onDeleteSelectedMovies: (Set<MovieId>) -> Unit,
onResetDatabase: () -> Unit,
) {
// ...
}
and we tweak this in Ui
show in full file app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
// ...
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
// ...
) {
// ...
NavDisplay(
// ...
entryProvider = entryProvider {
entry<MovieList>(
// ...
) {
// ...
MovieListUi(
movies = movies,
// onMovieClicked = { movie ->
// viewModel.pushScreen(MovieDisplay(movie.id))
onMovieClicked = { movieId ->
viewModel.pushScreen(MovieDisplay(movieId))
},
onDeleteSelectedMovies = { ids ->
// ...
)
}
// ...
}
)
}
All code changes
ADDED: 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
import com.androidbyexample.movies.repository.Id
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <ID: Id, TARGET_ID: Id, T: HasId<ID, TARGET_ID>> ListScaffold(
title: String,
items: List<T>,
onItemClicked: (TARGET_ID) -> Unit,
onDeleteSelectedItems: (Set<TARGET_ID>) -> Unit,
onResetDatabase: () -> Unit,
@DrawableRes itemIconId: Int,
@StringRes itemContentDescriptionId: Int,
modifier: Modifier = Modifier,
cardContent: @Composable (T) -> Unit,
) {
val selectedIds = rememberSaveable { mutableStateSetOf<ID>()}
fun onSelectionToggle(id: ID) {
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 = {
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 = {
val targetIds =
selectedIds
.asSequence()
.map { selectedId ->
items.find { it.id == selectedId }?.targetId
?: throw IllegalStateException("selected item state out of sync")
}
.toSet()
selectedIds.clear()
onDeleteSelectedItems(targetIds)
}
) {
Icon(
painter = painterResource(R.drawable.delete_24),
contentDescription = stringResource(R.string.delete_selected_items)
)
}
},
)
}
},
modifier = modifier,
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding)
) {
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)
.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/MovieListUi.kt
package com.androidbyexample.movies.screens
//import androidx.activity.compose.BackHandler
//import androidx.compose.foundation.BorderStroke
//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.helper.Display
import com.androidbyexample.movies.helper.ListScaffold
import com.androidbyexample.movies.repository.MovieDto
import com.androidbyexample.movies.repository.MovieId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieListUi(
movies: List<MovieDto>,
modifier: Modifier = Modifier,
// onMovieClicked: (MovieDto) -> Unit,
// onDeleteSelectedMovies: (Set<MovieId>) -> Unit,
onMovieClicked: (MovieId) -> Unit,
onDeleteSelectedMovies: (Set<MovieId>) -> Unit,
onResetDatabase: () -> Unit,
) {
// val selectedIds = rememberSaveable { mutableStateSetOf<MovieId>()}
//
// fun onSelectionToggle(id: MovieId) {
// 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 = stringResource(R.string.movies))
// },
// actions = {
// 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 = {
// onDeleteSelectedMovies(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.
// selectedIds.clear()
// }
// ) {
// Icon(
// painter = painterResource(R.drawable.delete_24),
// contentDescription = stringResource(R.string.delete_selected_items)
// )
// }
// },
// )
// }
// },
// modifier = modifier,
// ) { innerPadding ->
// LazyColumn(
// modifier = modifier
// .padding(innerPadding)
// ) {
// items(
ListScaffold(
title = stringResource(R.string.movies),
items = movies,
// key = { it.id }
onItemClicked = onMovieClicked,
onDeleteSelectedItems = onDeleteSelectedMovies,
onResetDatabase = onResetDatabase,
itemIconId = R.drawable.movie_24,
itemContentDescriptionId = R.string.movie,
modifier = modifier
) { movie ->
// val containerColor =
// if (movie.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,
// ),
// border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
// 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(
// painter = painterResource(R.drawable.movie_24),
// contentDescription = stringResource(R.string.movie),
// modifier = Modifier.clickable {
// onSelectionToggle(movie.id)
// }
// )
Display(text = movie.title)
}
// }
// }
// }
// }
}
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
package com.androidbyexample.movies.screens
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.androidbyexample.movies.MovieViewModel
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
viewModel: MovieViewModel,
) {
val listDetailStrategy = rememberListDetailSceneStrategy<Screen>()
val backStack by viewModel.backStackFlow.collectAsStateWithLifecycle(listOf(MovieList))
NavDisplay(
backStack = backStack,
onBack = viewModel::popScreen,
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider {
entry<MovieList>(
metadata = ListDetailSceneStrategy.listPane()
) {
val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
MovieListUi(
movies = movies,
// onMovieClicked = { movie ->
// viewModel.pushScreen(MovieDisplay(movie.id))
onMovieClicked = { movieId ->
viewModel.pushScreen(MovieDisplay(movieId))
},
onDeleteSelectedMovies = { ids ->
viewModel.deleteSelectedMovies(ids)
},
onResetDatabase = viewModel::doResetDatabase,
)
}
entry<MovieDisplay>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
MovieDisplayUi(
id = key.id,
fetchMovie = viewModel::getMovieWithCast,
)
}
}
)
}
CHANGED: repository/src/main/java/com/androidbyexample/movies/repository/ActorDto.kt
package com.androidbyexample.movies.repository
import android.os.Parcelable
import com.androidbyexample.movies.data.ActorEntity
import com.androidbyexample.movies.data.ActorEntityId
import com.androidbyexample.movies.data.ActorWithFilmography
import com.androidbyexample.movies.data.RoleWithMovie
import kotlinx.parcelize.Parcelize
data class ActorDto(
// val id: ActorId,
override val id: ActorId,
val name: String,
//)
): HasId<ActorId, ActorId>
internal fun ActorEntityId.toDtoId() = ActorId(value)
internal fun ActorId.toEntityId() = ActorEntityId(value)
internal fun ActorEntity.toDto() =
ActorDto(id = id.toDtoId(), name = name)
internal fun ActorDto.toEntity() =
ActorEntity(id = id.toEntityId(), name = name)
data class ActorWithFilmographyDto(
val actor: ActorDto,
val filmography: List<RoleWithMovieDto>,
)
@JvmInline @Parcelize value class RoleWithMovieId(override val value: String): Id, Parcelable
data class RoleWithMovieDto(
val movie: MovieDto,
val character: String,
val orderInCredits: Int,
//)
): HasId<RoleWithMovieId, MovieId> {
override val id: RoleWithMovieId
get() = RoleWithMovieId("${movie.id}:$orderInCredits")
override val targetId: MovieId
get() = movie.id
}
internal fun RoleWithMovie.toDto() =
RoleWithMovieDto(
movie = movie.toDto(),
character = role.character,
orderInCredits = role.orderInCredits,
)
internal fun ActorWithFilmography.toDto() =
ActorWithFilmographyDto(
actor = actor.toDto(),
filmography =
rolesWithMovies.map {
it.toDto()
}
)
CHANGED: repository/src/main/java/com/androidbyexample/movies/repository/ActorId.kt
package com.androidbyexample.movies.repository
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
//@JvmInline @Parcelize value class ActorId(val value: String): Parcelable
@JvmInline @Parcelize value class ActorId(override val value: String): Id, Parcelable
ADDED: repository/src/main/java/com/androidbyexample/movies/repository/HasId.kt
package com.androidbyexample.movies.repository
interface HasId<ID: Id, TARGET_ID: Id> {
val id: ID
@Suppress("UNCHECKED_CAST")
val targetId: TARGET_ID
get() = id as TARGET_ID
}
ADDED: repository/src/main/java/com/androidbyexample/movies/repository/Id.kt
package com.androidbyexample.movies.repository
interface Id {
val value: String
}
CHANGED: repository/src/main/java/com/androidbyexample/movies/repository/MovieDto.kt
package com.androidbyexample.movies.repository
import android.os.Parcelable
import com.androidbyexample.movies.data.MovieEntity
import com.androidbyexample.movies.data.MovieEntityId
import com.androidbyexample.movies.data.MovieWithCast
import com.androidbyexample.movies.data.RoleWithActor
import kotlinx.parcelize.Parcelize
data class MovieDto(
// val id: MovieId,
override val id: MovieId,
val title: String,
val description: String,
val ratingId: String,
//)
): HasId<MovieId, MovieId>
internal fun MovieEntityId.toDtoId() = MovieId(value)
internal fun MovieId.toEntityId() = MovieEntityId(value)
internal fun MovieEntity.toDto() =
MovieDto(id = id.toDtoId(), title = title, description = description, ratingId = ratingId)
internal fun MovieDto.toEntity() =
MovieEntity(id = id.toEntityId(), title = title, description = description, ratingId = ratingId)
data class MovieWithCastDto(
val movie: MovieDto,
val cast: List<RoleWithActorDto>,
)
@JvmInline @Parcelize value class RoleWithActorId(override val value: String): Id, Parcelable
data class RoleWithActorDto(
val actor: ActorDto,
val character: String,
val orderInCredits: Int,
//)
): HasId<RoleWithActorId, ActorId> {
override val id: RoleWithActorId
get() = RoleWithActorId("${actor.id}:$character")
override val targetId: ActorId
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/MovieId.kt
package com.androidbyexample.movies.repository
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
//@JvmInline @Parcelize value class MovieId(val value: String): Parcelable
@JvmInline @Parcelize value class MovieId(override val value: String): Id, Parcelable
CHANGED: repository/src/main/java/com/androidbyexample/movies/repository/RatingDto.kt
package com.androidbyexample.movies.repository
import com.androidbyexample.movies.data.RatingEntity
import com.androidbyexample.movies.data.RatingEntityId
import com.androidbyexample.movies.data.RatingWithMovies
data class RatingDto(
// val id: RatingId,
override val id: RatingId,
val name: String,
val description: String,
//)
): HasId<RatingId, RatingId>
internal fun RatingEntityId.toDtoId() = RatingId(value)
internal fun RatingId.toEntityId() = RatingEntityId(value)
internal fun RatingEntity.toDto() =
RatingDto(id = id.toDtoId(), name = name, description = description)
internal fun RatingDto.toEntity() =
RatingEntity(id = id.toEntityId(), name = name, description = description)
data class RatingWithMoviesDto(
val rating: RatingDto,
val movies: List<MovieDto>,
)
// only need the toDto(); we don't use this to do database updates
internal fun RatingWithMovies.toDto() =
RatingWithMoviesDto(
rating = rating.toDto(),
movies = movies.map { it.toDto() },
)
CHANGED: repository/src/main/java/com/androidbyexample/movies/repository/RatingId.kt
package com.androidbyexample.movies.repository
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
//@JvmInline @Parcelize value class RatingId(val value: String): Parcelable
@JvmInline @Parcelize value class RatingId(override val value: String): Id, Parcelable