Movies UI - Lists
Add Actors and Ratings
To flesh out the app more, let's add in Actors and Ratings.
First, we create the screen data to represent these new screens
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/Screens.kt
// ...
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
We'll create a common ListScaffold
that we can reuse on all "list" screens. (We'll define it
in a bit, but need some prep work first).
At the bottom of the list scaffold we'll place three buttons which act like tabs.
We define ScreenSelectButton
for these tabs.
show in full file app/src/main/java/com/androidbyexample/compose/movies/components/ScreenSelectButton.kt
// ...
import com.androidbyexample.compose.movies.screens.Screen
@Composable
fun RowScope.ScreenSelectButton(
currentScreen: Screen,
targetScreen: Screen,
imageVector: ImageVector,
@StringRes labelId: Int,
onSelectListScreen: (Screen) -> Unit,
) =
NavigationBarItem(
selected = currentScreen == targetScreen,
icon = {
Icon(
imageVector = imageVector,
contentDescription = stringResource(id = labelId)
)
},
label = {
Text(text = stringResource(id = labelId))
},
onClick = {
onSelectListScreen(targetScreen)
}
)
Each tab takes a Screen
as its target, and calls onSelectListScreen()
passing that target
screen when clicked. We use NavigationBarItem
, provided by the Material 3 library, for the
tab-like styling.
This type of navigation requires a new function in the view model to explicitly set the screen stack to the selected list screen.
show in full file app/src/main/java/com/androidbyexample/compose/movies/MovieViewModel.kt
// ...
class MovieViewModel(
// ...
): ViewModel(), MovieRepository by repository {
// ...
}
fun setScreen(screen: Screen) {
screenStack = listOf(screen)
}
fun deleteSelectedMovies() {
// ...
}
These buttons require some extra parameters to track which is selected (currentScreen
) and what
to do when they're clicked (onSelectListScreen
). We pass these up to all callers up through Ui
.
We need to pass the currentScreen
and onSelectListScreen
to Composables that will use our
ListScaffold
, which uses the ScreenSelectButton
s. We add them to MovieListUi
so we can pass
them to the ListScaffold
.
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
// ...
//@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) // for TopAppBar
@Composable
fun MovieListUi(
// ...
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
currentScreen: Screen,
onSelectListScreen: (Screen) -> Unit,
onDeleteSelectedMovies: () -> Unit,
onResetDatabase: () -> Unit,
) {
// ...
}
When calling MovieListUi
in Ui
, we'll need to pass values.
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/Ui.kt
// ...
@Composable
fun Ui(
// ...
) {
// ...
when (val screen = viewModel.currentScreen) {
// ...
MovieList -> {
// ...
MovieListUi(
// ...
onSelectionToggle = viewModel::toggleSelection,
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
currentScreen = screen,
onSelectListScreen = viewModel::setScreen,
onResetDatabase = {
scope.launch (Dispatchers.IO) {
// ...
)
}
// ...
}
}
We'll place these buttons in a NavigationBar
in the bottomBar
slot of the ListScaffold
.
This creates a nice Ui that allows us to navigate starting from movies, actors or ratings. The screen tabs could be moved into a common base scaffold for all screens to allow instant jumping to a list from any screen (I'll leave that as an "exercise for the interested reader").
Now we create the ListScaffold
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/ListScaffold.kt
// ...
@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,
modifier: Modifier = Modifier,
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.Star,
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,
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
itemContent = itemContent,
)
}
}
and use it in MovieListUi
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
// ...
//@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) // for TopAppBar
@Composable
fun MovieListUi(
// ...
onResetDatabase: () -> Unit,
) {
// Scaffold(
// topBar = {
// if (selectedIds.isEmpty()) {
// TopAppBar(
// title = { Text(text = stringResource(R.string.movies)) },
// actions = {
// IconButton(onClick = onResetDatabase) {
// Icon(
// imageVector = Icons.Default.Refresh,
// contentDescription = stringResource(R.string.reset_database)
// )
// }
// }
// )
// } else {
// TopAppBar(
// navigationIcon = {
// Icon(
// imageVector = Icons.AutoMirrored.Filled.ArrowBack,
// contentDescription = stringResource(R.string.clear_selections),
// modifier = Modifier.clickable(onClick = onClearSelections),
// )
// },
// title = {
// Text(
// text = selectedIds.size.toString(),
// modifier = Modifier.padding(8.dp)
// )
// },
// actions = {
// IconButton(onClick = onDeleteSelectedMovies) {
// Icon(
// imageVector = Icons.Default.Delete,
// contentDescription = stringResource(R.string.delete_selected_items)
// )
// }
// },
// )
// }
// },
// modifier = modifier,
// ) { innerPadding ->
// List(
ListScaffold(
titleId = R.string.movies,
items = movies,
onItemClicked = onMovieClicked,
selectedIds = selectedIds,
onSelectionToggle = onSelectionToggle,
onClearSelections = onClearSelections,
// modifier = Modifier
// .padding(innerPadding)
// .fillMaxSize()
onDeleteSelectedItems = onDeleteSelectedMovies,
currentScreen = currentScreen,
onSelectListScreen = onSelectListScreen,
onResetDatabase = onResetDatabase,
modifier = modifier,
) { movie ->
// Row (
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = stringResource(id = R.string.movie),
modifier = Modifier.clickable {
onSelectionToggle(movie.id)
}
)
Display(text = movie.title)
}
}
// }
}
Be sure to fixup the Modifier
calls to pass fillMaxSize
from the activity:
show in full file app/src/main/java/com/androidbyexample/compose/movies/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
MoviesTheme {
Ui(
viewModel = viewModel,
modifier = Modifier.fillMaxSize()
) {
finish()
}
}
}
}
}
We're going to need to tweak our Label
helper. Right now, we pass a string resource id, which it
looks up. For our new screens, we'll be using arguments in strings, so we'll need to resolve
them outside of the Label
. We add an overload that takes a String
and modify the original
to resolve the string resource id and pass it to the new function.
show in full file app/src/main/java/com/androidbyexample/compose/movies/components/Label.kt
// ...
@Composable
fun Label(
@StringRes textId: Int,
modifier: Modifier = Modifier,
) {
Label(
text = stringResource(textId),
modifier = modifier,
)
}
@Composable
fun Label(
text: String,
modifier: Modifier = Modifier,
) {
Text(
// text = stringResource(id = textId),
text = text,
style = MaterialTheme.typography.titleMedium,
modifier = modifier
.padding(8.dp)
.fillMaxWidth(),
)
}
The new actor display screen will use RoleWithMovieDto
in a list. We need to make it
implement HasId
to work with our generic ListUi
. We'll derive the id
property in the same way
we did for RoleWithActorDto
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/ActorDto.kt
// ...
data class RoleWithMovieDto(
val movie: MovieDto,
val character: String,
val orderInCredits: Int,
//)
): HasId {
override val id: String
get() = "${movie.id}:$character"
}
internal fun RoleWithMovie.toDto() =
RoleWithMovieDto(
// ...
Now we add the new screens. These new screens are similar to the existing screens, but be careful
of where you get the ids from. The MovieDisplayUi
, for example, displays a list of
RoleWithActorDto
s, and when clicked, we need to call it.actor.id
to get the proper id.
(If you use just it.id
here, the fetch will fail when looking up the actor)
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/ActorList.kt
// ...
import com.androidbyexample.compose.movies.repository.ActorDto
@Composable
fun ActorListUi(
actors: List<ActorDto>,
onActorClicked: (ActorDto) -> Unit,
selectedIds: Set<String>,
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
onDeleteSelectedActors: () -> Unit,
currentScreen: Screen,
onSelectListScreen: (Screen) -> Unit,
onResetDatabase: () -> Unit,
) {
ListScaffold(
titleId = R.string.actors,
items = actors,
onItemClicked = onActorClicked,
selectedIds = selectedIds,
onSelectionToggle = onSelectionToggle,
onClearSelections = onClearSelections,
onDeleteSelectedItems = onDeleteSelectedActors,
currentScreen = currentScreen,
onSelectListScreen = onSelectListScreen,
onResetDatabase = onResetDatabase
) { actor ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = stringResource(id = R.string.actor),
modifier = Modifier.clickable {
onSelectionToggle(actor.id)
}
)
Display(text = actor.name)
}
}
}
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/RatingList.kt
// ...
import com.androidbyexample.compose.movies.repository.RatingDto
@Composable
fun RatingListUi(
ratings: List<RatingDto>,
onRatingClicked: (RatingDto) -> Unit,
selectedIds: Set<String>,
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
onDeleteSelectedRatings: () -> Unit,
currentScreen: Screen,
onSelectListScreen: (Screen) -> Unit,
onResetDatabase: () -> Unit,
) {
ListScaffold(
titleId = R.string.ratings,
items = ratings,
onItemClicked = onRatingClicked,
selectedIds = selectedIds,
onSelectionToggle = onSelectionToggle,
onClearSelections = onClearSelections,
onDeleteSelectedItems = onDeleteSelectedRatings,
currentScreen = currentScreen,
onSelectListScreen = onSelectListScreen,
onResetDatabase = onResetDatabase
) { rating ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = stringResource(id = R.string.rating),
modifier = Modifier.clickable {
onSelectionToggle(rating.id)
}
)
Display(text = rating.name)
}
}
}
Next we create the display screens for actor and rating
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/ActorDisplay.kt
// ...
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActorDisplayUi(
id: String,
fetchActor: suspend (String) -> ActorWithFilmographyDto,
onMovieClicked: (MovieDto) -> Unit,
) {
var actorWithFilmography by remember { mutableStateOf<ActorWithFilmographyDto?>(null) }
LaunchedEffect(key1 = id) {
withContext(Dispatchers.IO) {
actorWithFilmography = fetchActor(id)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = actorWithFilmography?.actor?.name ?: stringResource(R.string.loading))
}
)
}
) { paddingValues ->
actorWithFilmography?.let { actorWithFilmography ->
Column(
modifier = Modifier
.padding(paddingValues)
) {
Label(textId = R.string.name)
Display(text = actorWithFilmography.actor.name)
Label(
text = stringResource(
id = R.string.movies_starring,
actorWithFilmography.actor.name
)
)
List(
items = actorWithFilmography.filmography.sortedBy { it.movie.title },
onItemClicked = { onMovieClicked(it.movie) },
selectedIds = emptySet(),
onSelectionToggle = {},
onClearSelections = {},
modifier = Modifier.weight(1f)
) { role ->
Display(text = role.movie.title)
}
}
}
}
}
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/RatingDisplay.kt
// ...
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RatingDisplayUi(
id: String,
fetchRating: suspend (String) -> RatingWithMoviesDto,
onMovieClicked: (MovieDto) -> Unit,
) {
var ratingWithMovies by remember { mutableStateOf<RatingWithMoviesDto?>(null) }
LaunchedEffect(key1 = id) {
withContext(Dispatchers.IO) {
ratingWithMovies = fetchRating(id)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = ratingWithMovies?.rating?.name ?: stringResource(R.string.loading))
}
)
}
) { paddingValues ->
ratingWithMovies?.let { ratingWithMovies ->
Column(
modifier = Modifier
.padding(paddingValues)
) {
Label(textId = R.string.name)
Display(text = ratingWithMovies.rating.name)
Label(textId = R.string.description)
Display(text = ratingWithMovies.rating.description)
Label(
text = stringResource(
id = R.string.movies_rated,
ratingWithMovies.rating.name
)
)
List(
items = ratingWithMovies.movies.sortedBy { it.title },
onItemClicked = onMovieClicked,
selectedIds = emptySet(),
onSelectionToggle = {},
onClearSelections = {},
modifier = Modifier.weight(1f)
) { movie ->
Display(
text = movie.title
)
}
}
}
}
}
Define new user-facing strings
show in full file app/src/main/res/values/strings.xml
<resources>
// ...
<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>
</resources>
Fix the onActorClicked
parameter being passed to MovieDisplayUi
to push the screen
for the clicked RoleWithActorDto
Finally, we add the calls to the new screens 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(
id = screen.id,
fetchMovie = viewModel::getMovieWithCast,
// onActorClicked = { }, // empty for now. we'll fix later
onActorClicked = { viewModel.pushScreen(ActorDisplay(it.actor.id)) },
modifier = modifier,
)
}
is ActorDisplay -> {
ActorDisplayUi(
id = screen.id,
fetchActor = viewModel::getActorWithFilmography,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it.id)) }
)
}
is RatingDisplay -> {
RatingDisplayUi(
id = screen.id,
fetchRating = viewModel::getRatingWithMovies,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it.id)) }
)
}
MovieList -> {
val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(
// ...
)
}
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()
}
}
)
}
}
}
When we run this version of the application, we can now drill deeper into the data. Pick a movie, then pick an actor, then pick movies starring that actor and so forth. Navigating back pops each screen off the stack, returning to the previous screen.
Note
We're still using a Star icon for movies. We'll pick a better icon in the final step.
You'll notice that we currently have no means of navigating to the actor and rating list screens. To do this, we'll add tabs at the bottom of the list screens to jump to other lists.
Navigation-wise, we'll treat each of these jumps as restarting navigation, replacing the stack with the destination. This isn't necessary, but I wanted to demonstrate a navigation alternative.
Now let's get a better movie icon...
All code changes
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/MainActivity.kt
package com.androidbyexample.compose.movies
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import com.androidbyexample.compose.movies.screens.Ui
import com.androidbyexample.compose.movies.ui.theme.MoviesTheme
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MovieViewModel> { MovieViewModel.Factory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MoviesTheme {
Ui(
viewModel = viewModel,
modifier = Modifier.fillMaxSize()
) {
finish()
}
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/MovieViewModel.kt
package com.androidbyexample.compose.movies
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.androidbyexample.compose.movies.repository.MovieDatabaseRepository
import com.androidbyexample.compose.movies.repository.MovieRepository
import com.androidbyexample.compose.movies.screens.MovieList
import com.androidbyexample.compose.movies.screens.Screen
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class MovieViewModel(
private val repository: MovieRepository,
): ViewModel(), MovieRepository by repository {
private val _selectedIdsFlow = MutableStateFlow<Set<String>>(emptySet())
val selectedIdsFlow: Flow<Set<String>> = _selectedIdsFlow.asStateFlow()
fun clearSelectedIds() {
_selectedIdsFlow.value = emptySet()
}
fun toggleSelection(id: String) {
if (id in _selectedIdsFlow.value) {
_selectedIdsFlow.value -= id
} else {
_selectedIdsFlow.value += id
}
}
private var screenStack = listOf<Screen>(MovieList)
set(value) {
field = value
clearSelectedIds()
currentScreen = value.lastOrNull()
}
// NOTE: We're keep this as a Compose State for comparison.
// You can use Compose state to expose anything from the view model,
// but our example will be using Flow from now on to demonstrate how
// the view model can be used without Compose, perhaps for other
// platforms such as iOS, desktop, web or command line
var currentScreen by mutableStateOf<Screen?>(MovieList)
private set
fun pushScreen(screen: Screen) {
screenStack = screenStack + screen
}
fun popScreen() {
screenStack = screenStack.dropLast(1)
}
fun setScreen(screen: Screen) {
screenStack = listOf(screen)
}
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/components/Label.kt
package com.androidbyexample.compose.movies.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@Composable
fun Label(
@StringRes textId: Int,
modifier: Modifier = Modifier,
) {
Label(
text = stringResource(textId),
modifier = modifier,
)
}
@Composable
fun Label(
text: String,
modifier: Modifier = Modifier,
) {
Text(
// text = stringResource(id = textId),
text = text,
style = MaterialTheme.typography.titleMedium,
modifier = modifier
.padding(8.dp)
.fillMaxWidth(),
)
}
ADDED: app/src/main/java/com/androidbyexample/compose/movies/components/ScreenSelectButton.kt
package com.androidbyexample.compose.movies.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import com.androidbyexample.compose.movies.screens.Screen
ADDED: app/src/main/java/com/androidbyexample/compose/movies/screens/ActorDisplay.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.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.R
import com.androidbyexample.compose.movies.components.Display
import com.androidbyexample.compose.movies.components.Label
import com.androidbyexample.compose.movies.repository.ActorWithFilmographyDto
import com.androidbyexample.compose.movies.repository.MovieDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActorDisplayUi(
id: String,
fetchActor: suspend (String) -> ActorWithFilmographyDto,
onMovieClicked: (MovieDto) -> Unit,
) {
var actorWithFilmography by remember { mutableStateOf<ActorWithFilmographyDto?>(null) }
LaunchedEffect(key1 = id) {
withContext(Dispatchers.IO) {
actorWithFilmography = fetchActor(id)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = actorWithFilmography?.actor?.name ?: stringResource(R.string.loading))
}
)
}
) { paddingValues ->
actorWithFilmography?.let { actorWithFilmography ->
Column(
modifier = Modifier
.padding(paddingValues)
) {
Label(textId = R.string.name)
Display(text = actorWithFilmography.actor.name)
Label(
text = stringResource(
id = R.string.movies_starring,
actorWithFilmography.actor.name
)
)
List(
items = actorWithFilmography.filmography.sortedBy { it.movie.title },
onItemClicked = { onMovieClicked(it.movie) },
selectedIds = emptySet(),
onSelectionToggle = {},
onClearSelections = {},
modifier = Modifier.weight(1f)
) { role ->
Display(text = role.movie.title)
}
}
}
}
}
ADDED: app/src/main/java/com/androidbyexample/compose/movies/screens/ActorList.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.Person
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.androidbyexample.compose.movies.R
import com.androidbyexample.compose.movies.components.Display
import com.androidbyexample.compose.movies.repository.ActorDto
@Composable
fun ActorListUi(
actors: List<ActorDto>,
onActorClicked: (ActorDto) -> Unit,
selectedIds: Set<String>,
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
onDeleteSelectedActors: () -> Unit,
currentScreen: Screen,
onSelectListScreen: (Screen) -> Unit,
onResetDatabase: () -> Unit,
) {
ListScaffold(
titleId = R.string.actors,
items = actors,
onItemClicked = onActorClicked,
selectedIds = selectedIds,
onSelectionToggle = onSelectionToggle,
onClearSelections = onClearSelections,
onDeleteSelectedItems = onDeleteSelectedActors,
currentScreen = currentScreen,
onSelectListScreen = onSelectListScreen,
onResetDatabase = onResetDatabase
) { actor ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = stringResource(id = R.string.actor),
modifier = Modifier.clickable {
onSelectionToggle(actor.id)
}
)
Display(text = actor.name)
}
}
}
ADDED: app/src/main/java/com/androidbyexample/compose/movies/screens/ListScaffold.kt
package com.androidbyexample.compose.movies.screens
import androidx.annotation.StringRes
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
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.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,
modifier: Modifier = Modifier,
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.Star,
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,
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
itemContent = itemContent,
)
}
}
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
package com.androidbyexample.compose.movies.screens
//import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
//import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row
//import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
//import androidx.compose.material.icons.automirrored.filled.ArrowBack
//import androidx.compose.material.icons.filled.Delete
//import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Star
//import androidx.compose.material3.Card
//import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
//import androidx.compose.material3.IconButton
//import androidx.compose.material3.MaterialTheme
//import androidx.compose.material3.Scaffold
//import androidx.compose.material3.Text
//import androidx.compose.material3.TopAppBar
//import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.androidbyexample.compose.movies.R
import com.androidbyexample.compose.movies.components.Display
import com.androidbyexample.compose.movies.repository.MovieDto
//@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) // for TopAppBar
@Composable
fun MovieListUi(
movies: List<MovieDto>,
modifier: Modifier = Modifier,
onMovieClicked: (MovieDto) -> Unit,
selectedIds: Set<String>,
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
currentScreen: Screen,
onSelectListScreen: (Screen) -> Unit,
onDeleteSelectedMovies: () -> Unit,
onResetDatabase: () -> Unit,
) {
// Scaffold(
// topBar = {
// if (selectedIds.isEmpty()) {
// TopAppBar(
// title = { Text(text = stringResource(R.string.movies)) },
// actions = {
// IconButton(onClick = onResetDatabase) {
// Icon(
// imageVector = Icons.Default.Refresh,
// contentDescription = stringResource(R.string.reset_database)
// )
// }
// }
// )
// } else {
// TopAppBar(
// navigationIcon = {
// Icon(
// imageVector = Icons.AutoMirrored.Filled.ArrowBack,
// contentDescription = stringResource(R.string.clear_selections),
// modifier = Modifier.clickable(onClick = onClearSelections),
// )
// },
// title = {
// Text(
// text = selectedIds.size.toString(),
// modifier = Modifier.padding(8.dp)
// )
// },
// actions = {
// IconButton(onClick = onDeleteSelectedMovies) {
// Icon(
// imageVector = Icons.Default.Delete,
// contentDescription = stringResource(R.string.delete_selected_items)
// )
// }
// },
// )
// }
// },
// modifier = modifier,
// ) { innerPadding ->
// List(
ListScaffold(
titleId = R.string.movies,
items = movies,
onItemClicked = onMovieClicked,
selectedIds = selectedIds,
onSelectionToggle = onSelectionToggle,
onClearSelections = onClearSelections,
// modifier = Modifier
// .padding(innerPadding)
// .fillMaxSize()
onDeleteSelectedItems = onDeleteSelectedMovies,
currentScreen = currentScreen,
onSelectListScreen = onSelectListScreen,
onResetDatabase = onResetDatabase,
modifier = modifier,
) { movie ->
// Row (
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = stringResource(id = R.string.movie),
modifier = Modifier.clickable {
onSelectionToggle(movie.id)
}
)
Display(text = movie.title)
}
}
// }
}
ADDED: app/src/main/java/com/androidbyexample/compose/movies/screens/RatingDisplay.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.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.R
import com.androidbyexample.compose.movies.components.Display
import com.androidbyexample.compose.movies.components.Label
import com.androidbyexample.compose.movies.repository.MovieDto
import com.androidbyexample.compose.movies.repository.RatingWithMoviesDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RatingDisplayUi(
id: String,
fetchRating: suspend (String) -> RatingWithMoviesDto,
onMovieClicked: (MovieDto) -> Unit,
) {
var ratingWithMovies by remember { mutableStateOf<RatingWithMoviesDto?>(null) }
LaunchedEffect(key1 = id) {
withContext(Dispatchers.IO) {
ratingWithMovies = fetchRating(id)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = ratingWithMovies?.rating?.name ?: stringResource(R.string.loading))
}
)
}
) { paddingValues ->
ratingWithMovies?.let { ratingWithMovies ->
Column(
modifier = Modifier
.padding(paddingValues)
) {
Label(textId = R.string.name)
Display(text = ratingWithMovies.rating.name)
Label(textId = R.string.description)
Display(text = ratingWithMovies.rating.description)
Label(
text = stringResource(
id = R.string.movies_rated,
ratingWithMovies.rating.name
)
)
List(
items = ratingWithMovies.movies.sortedBy { it.title },
onItemClicked = onMovieClicked,
selectedIds = emptySet(),
onSelectionToggle = {},
onClearSelections = {},
modifier = Modifier.weight(1f)
) { movie ->
Display(
text = movie.title
)
}
}
}
}
}
ADDED: app/src/main/java/com/androidbyexample/compose/movies/screens/RatingList.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.Star
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.androidbyexample.compose.movies.R
import com.androidbyexample.compose.movies.components.Display
import com.androidbyexample.compose.movies.repository.RatingDto
@Composable
fun RatingListUi(
ratings: List<RatingDto>,
onRatingClicked: (RatingDto) -> Unit,
selectedIds: Set<String>,
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
onDeleteSelectedRatings: () -> Unit,
currentScreen: Screen,
onSelectListScreen: (Screen) -> Unit,
onResetDatabase: () -> Unit,
) {
ListScaffold(
titleId = R.string.ratings,
items = ratings,
onItemClicked = onRatingClicked,
selectedIds = selectedIds,
onSelectionToggle = onSelectionToggle,
onClearSelections = onClearSelections,
onDeleteSelectedItems = onDeleteSelectedRatings,
currentScreen = currentScreen,
onSelectListScreen = onSelectListScreen,
onResetDatabase = onResetDatabase
) { rating ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = stringResource(id = R.string.rating),
modifier = Modifier.clickable {
onSelectionToggle(rating.id)
}
)
Display(text = rating.name)
}
}
}
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
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/screens/Ui.kt
package com.androidbyexample.compose.movies.screens
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.androidbyexample.compose.movies.MovieViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun Ui(
viewModel: MovieViewModel,
modifier: Modifier = Modifier,
onExit: () -> Unit,
) {
BackHandler {
viewModel.popScreen()
}
val scope = rememberCoroutineScope()
when (val screen = viewModel.currentScreen) {
null -> onExit()
is MovieDisplay -> {
MovieDisplayUi(
id = screen.id,
fetchMovie = viewModel::getMovieWithCast,
modifier = modifier,
)
}
is ActorDisplay -> {
ActorDisplayUi(
id = screen.id,
fetchActor = viewModel::getActorWithFilmography,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it.id)) }
)
}
is RatingDisplay -> {
RatingDisplayUi(
id = screen.id,
fetchRating = viewModel::getRatingWithMovies,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it.id)) }
)
}
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>
</resources>
CHANGED: repository/src/main/java/com/androidbyexample/compose/movies/repository/ActorDto.kt
package com.androidbyexample.compose.movies.repository
import com.androidbyexample.compose.movies.data.ActorEntity
import com.androidbyexample.compose.movies.data.ActorWithFilmography
import com.androidbyexample.compose.movies.data.RoleWithMovie
data class ActorDto(
override val id: String,
val name: String,
): HasId
internal fun ActorEntity.toDto() =
ActorDto(id = id, name = name)
internal fun ActorDto.toEntity() =
ActorEntity(id = id, name = name)
data class ActorWithFilmographyDto(
val actor: ActorDto,
val filmography: List<RoleWithMovieDto>,
)
data class RoleWithMovieDto(
val movie: MovieDto,
val character: String,
val orderInCredits: Int,
//)
): HasId {
override val id: String
get() = "${movie.id}:$character"
}
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()
}
)