Skip to content

Movies UI - Lists

Add Actors and Ratings

To flesh out the app more, let's add in Actors and Ratings.

We need some way to navigate between the lists. We'll do this by adding a navigation bar at the bottom of the ListScaffold. This contains buttons that act list tabs.

Screen tabs

But what if the screen is larger? Instead of tabs at the bottom, we can also have tabs on the side. To accomplish this, we wrap our overall Scaffold with a NavigationSuiteScaffold that manages the buttons. (If we always wanted bottom buttons, we could define them in the bottomBar of the Scaffold)

We add the navigation suite to our version catalog:

show in full file gradle/libs.versions.toml
[versions]
// ...
[libraries]
// ...
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation" }
androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
// ...
[plugins]
// ...

Note

The compose navigation suite is included in the Compose Bill-Of-Materials (BOM), so we don't need a version specification. Because our build script (app/build.gradle.kts) includes the Compose BOM, we can just reference the library.

show in full file app/build.gradle.kts
// ...
dependencies {
    // ...
    implementation(libs.androidx.material3.adaptive.navigation3)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.compose.material3)
    // ...
}

Each tab is represented as a "navigation suite item" that has an icon, label, and selection management. To make this simpler to set up, let's tweak our screen data to include a ListScreen class that contains the icon and label ids.

show in full file app/src/main/java/com/androidbyexample/movies/screens/Screens.kt
// ...

sealed interface Screen: Parcelable
sealed class ListScreen(
    @DrawableRes val iconId: Int,
    @StringRes val labelId: Int,
): Screen

@Parcelize
//data object MovieList: Screen
data object MovieList: ListScreen(
    iconId = R.drawable.movie_24,
    labelId = R.string.movies,
)
@Parcelize
data object RatingList: ListScreen(
    iconId = R.drawable.star_24,
    labelId = R.string.ratings,
)
@Parcelize
data object ActorList: ListScreen(
    iconId = R.drawable.person_24,
    labelId = R.string.actors,
)

@Parcelize
// ...

In our view model, we'll track which list screen is currently selected

show in full file app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
// ...
class MovieViewModel(
    // ...
): ViewModel(), MovieRepository by repository {

    val currentListScreenFlow = savedStateHandle.getMutableStateFlow<ListScreen>(
        key = "current_list_screen",
        initialValue = MovieList
    )

    fun goToListScreen(screen: ListScreen) {
        currentListScreenFlow.value = screen
        backStackFlow.value = listOf(screen)
    }

    val backStackFlow = savedStateHandle.getMutableStateFlow<List<Screen>>(
        // ...
}

Then we add support in Ui() to wrap everything with a navigation scaffold

show in full file app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
// ...

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
    // ...
) {
    // ...
    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()
                    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 ->
                    MovieDisplayUi(
                        id = key.id,
                        fetchMovie = viewModel::getMovieWithCast,
//                  onActorClicked = {},
                        onActorClicked = { viewModel.pushScreen(ActorDisplay(it)) },
                        onDeleteSelectedActors = { },
//                  onResetDatabase = viewModel::doResetDatabase,
                    )
                }
                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 ->
                    ActorDisplayUi(
                        id = key.id,
                        fetchActor = viewModel::getActorWithFilmography,
                        onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) },
                        onDeleteSelectedMovies = { },
                    )
                }
                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 ->
                    RatingDisplayUi(
                        id = key.id,
                        fetchRating = viewModel::getRatingWithMovies,
                        onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) },
                        onDeleteSelectedMovies = { },
                    )
                }
            }
        )
    }
}

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").

Actor and Rating Screens

Now we add the new screens. These new screens are similar to the existing screens

show in full file app/src/main/java/com/androidbyexample/movies/screens/ActorListUi.kt
package com.androidbyexample.movies.screens

import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
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.ListScaffold
import com.androidbyexample.movies.repository.ActorDto

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActorListUi(
    actors: List<ActorDto>,
    modifier: Modifier = Modifier,
    onActorClicked: (String) -> Unit,
    onDeleteSelectedActors: (Set<String>) -> Unit,
    onResetDatabase: () -> Unit,
) {
    ListScaffold(
        title = stringResource(R.string.actors),
        items = actors,
        onItemClicked = onActorClicked,
        onDeleteSelectedItems = onDeleteSelectedActors,
        onResetDatabase = onResetDatabase,
        itemIconId = R.drawable.person_24,
        itemContentDescriptionId = R.string.actor,
        modifier = modifier
    ) { actor ->
        Display(text = actor.name)
    }
}
show in full file app/src/main/java/com/androidbyexample/movies/screens/RatingListUi.kt
package com.androidbyexample.movies.screens

import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
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.ListScaffold
import com.androidbyexample.movies.repository.RatingDto

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RatingListUi(
    ratings: List<RatingDto>,
    modifier: Modifier = Modifier,
    onRatingClicked: (String) -> Unit,
    onDeleteSelectedRatings: (Set<String>) -> Unit,
    onResetDatabase: () -> Unit,
) {
    ListScaffold(
        title = stringResource(R.string.ratings),
        items = ratings,
        onItemClicked = onRatingClicked,
        onDeleteSelectedItems = onDeleteSelectedRatings,
        onResetDatabase = onResetDatabase,
        itemIconId = R.drawable.star_24,
        itemContentDescriptionId = R.string.rating,
        modifier = modifier
    ) { rating ->
        Display(text = rating.name)
    }
}

Next we create the display screens for actor and rating

show in full file app/src/main/java/com/androidbyexample/movies/screens/ActorDisplayUi.kt
// ...
import kotlinx.coroutines.withContext

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActorDisplayUi(
    id: String,
    fetchActor: suspend (String) -> 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,
                    )
                )
            },
        )
    }
}
show in full file app/src/main/java/com/androidbyexample/movies/screens/RatingDisplayUi.kt
// ...
import kotlinx.coroutines.withContext

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RatingDisplayUi(
    id: String,
    fetchRating: suspend (String) -> 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
                )
            },
        )
    }
}

These require a tweak to Label, as we're using a text resource with filled-in arguments.

show in full file app/src/main/java/com/androidbyexample/movies/helper/Label.kt
// ...

@Composable
fun Label(
    @StringRes textId: Int,
    vararg formatArgs: Any,
    modifier: Modifier = Modifier,
) {
    Text(
//      text = stringResource(id = textId),
        text = stringResource(id = textId, formatArgs = formatArgs),
        style = MaterialTheme.typography.titleMedium,
        modifier = modifier
            .padding(8.dp)
            .fillMaxWidth()
    )
}

Define new user-facing strings

show in full file app/src/main/res/values/strings.xml
<resources>
    <string name="app_name">movies</string>
    <string name="movies">Movies</string>
    <string name="actors">Actors</string>
    <string name="ratings">Ratings</string>
    <string name="actor">Actor</string>
    <string name="rating">Rating</string>
    <string name="loading">Loading…</string>
    <string name="name">Name</string>
    <string name="movies_starring">Movies Starring %1$s</string>
    <string name="movies_rated">Movies Rated %1$s</string>
    <string name="title">Title</string>
    <string name="description">Description</string>
    // ...
</resources>

Fix the onActorClicked parameter being passed to MovieDisplayUi to push the screen for the clicked RoleWithActorDto, and add the new screens to the Ui

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>(
                    // ...
                ) { key ->
                    MovieDisplayUi(
                        id = key.id,
                        fetchMovie = viewModel::getMovieWithCast,
//                  onActorClicked = {},
                        onActorClicked = { viewModel.pushScreen(ActorDisplay(it)) },
                        onDeleteSelectedActors = { },
                    // ...
                    )
                }
                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 ->
                    ActorDisplayUi(
                        id = key.id,
                        fetchActor = viewModel::getActorWithFilmography,
                        onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) },
                        onDeleteSelectedMovies = { },
                    )
                }
                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 ->
                    RatingDisplayUi(
                        id = key.id,
                        fetchRating = viewModel::getRatingWithMovies,
                        onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) },
                        onDeleteSelectedMovies = { },
                    )
                }
            }
        )
    }
}

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.

Finally, a quick tweak to animate list changes (such as when items are added or deleted from a list)

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(
        // ...
    ) { innerPadding ->
        LazyColumn(
            // ...
        ) {
            // ...
            items(
                // ...
            ) { item ->
                // ...
                Card(
                    // ...
                    modifier = Modifier
                        .padding(8.dp)
                        .animateItem()
                        .combinedClickable(
                            // NOTE - use targetId for navigation, id for selections
                            // ...
                        )
                ) {
                    // ...
                }
            }
        }
    }
}

Animated deletion

There is one little glitch when we try rotating the screen. A nasty gap between the panes:

Nasty gap between panes

Note that this wouldn't be visible if we used a single Scaffold around the entire screen, rather that defining it at the list or display screen level. But because we want different identifiers (and later, actions) above each section, we see the gap. Depending on what you're trying to accomplish, you may want the gap, but here it looks wrong.

To fix this, we need to tweak how we create our list/detail strategy:

show in full file app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
// ...

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
    viewModel: MovieViewModel,
) {
//  val listDetailStrategy = rememberListDetailSceneStrategy<Screen>()
    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))
    // ...
}

No more gap!

But what about that duplicate "refresh" icon? For this app, let's set it up so that it only appears on a list screen. We can do this by making onResetDatabase nullable in our ListScaffold and only show the button if it's non-null:

show in full file app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
// ...

@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <T: HasId> ListScaffold(
    // ...
    onItemClicked: (String) -> Unit,
    onDeleteSelectedItems: (Set<String>) -> Unit,
//  onResetDatabase: () -> Unit,
    onResetDatabase: (() -> Unit)?,
    @DrawableRes itemIconId: Int,
    @StringRes itemContentDescriptionId: Int,
    // ...
) {
    // ...
    Scaffold(
        topBar = {
            // ...
            if (selectedIds.isEmpty()) {
                TopAppBar(
                    // ...
                    },
                    actions = {
                        onResetDatabase?.let {
                            IconButton(onClick = onResetDatabase) {
                                Icon(
                                    painter = painterResource(R.drawable.refresh_24),
                                    contentDescription = stringResource(R.string.reset_database),
                                )
                            }
                        }
                    }
                )
            } else {
                // ...
            }
        },
        // ...
    ) { innerPadding ->
        // ...
    }
}

and then passing null from the display screens (and we can remove their onResetDatabase parameters)

show in full file app/src/main/java/com/androidbyexample/movies/screens/MovieDisplayUi.kt
// ...

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieDisplayUi(
    // ...
) {
    // ...
    movieWithCast?.let { movieWithCast ->
        ListScaffold(
            // ...
            onItemClicked = onActorClicked,
            onDeleteSelectedItems = onDeleteSelectedActors,
//          onResetDatabase = onResetDatabase,
            onResetDatabase = null,
            itemIconId = R.drawable.movie_24,
            itemContentDescriptionId = R.string.movie,
            // ...
        )
    } ?: run {
        // ...
    }
}

And it looks better

Only one reset button

But there is one more thing that looks off. If we start with a wide screen, we only see the movie list on the left.

Just the list looks odd

Fortunately, we can provide a placeholder for the detail pane:

show in full file app/src/main/java/com/androidbyexample/movies/screens/DetailPlaceholder.kt
// ...
import androidx.compose.ui.res.stringResource

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailPlaceholder(
    @StringRes messageId: Int,
) {
    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
            )
            TopAppBar(
                colors = colors,
                title = {},
            )
        }
    ) { innerPadding ->
        Box(
            modifier =
                Modifier
                    .padding(innerPadding)
                    .fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text(stringResource(id = messageId))
        }
    }
}

and use it when setting up our list navigation

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<MovieList>(
//              metadata = ListDetailSceneStrategy.listPane()
                    metadata = ListDetailSceneStrategy.listPane(
                        detailPlaceholder = { DetailPlaceholder(R.string.select_a_movie_to_view) }
                    )
                ) {
                    val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(
                        // ...
                }
                // ...
            }
        )
    }
}

Unfortunately, with the way our displays load, they start blank while loading their data, causing a flash in the detail area. We can fix this by using the placeholder in an Elvis clause:

show in full file app/src/main/java/com/androidbyexample/movies/screens/MovieDisplayUi.kt
// ...

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieDisplayUi(
    // ...
) {
    // ...
    movieWithCast?.let { movieWithCast ->
        // ...
            },
        )
    } ?: run {
        DetailPlaceholder(messageId = R.string.loading)
    }
}

s


All code changes

CHANGED: app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
    id("kotlin-parcelize")
}

android {
    namespace = "com.androidbyexample.movies"
    compileSdk {
        version = release((libs.versions.compileSdk.get().toInt()))
    }

    defaultConfig {
        applicationId = "com.androidbyexample.movies"
        minSdk = libs.versions.minSdk.get().toInt()
        targetSdk = libs.versions.targetSdk.get().toInt()
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            optimization {
                enable = false
            }
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get())
        targetCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get())
    }
    buildFeatures {
        compose = true
    }
}

dependencies {
    implementation(project(":repository"))
    implementation(libs.androidx.navigation3.ui)
    implementation(libs.androidx.navigation3.runtime)
    implementation(libs.androidx.material3.adaptive.navigation3)
implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) }
CHANGED: app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
package com.androidbyexample.movies

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.androidbyexample.movies.repository.MovieDatabaseRepository
import com.androidbyexample.movies.repository.MovieRepository
import com.androidbyexample.movies.screens.ListScreen
import com.androidbyexample.movies.screens.MovieList
import com.androidbyexample.movies.screens.Screen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class MovieViewModel(
    private val savedStateHandle: SavedStateHandle,
    private val repository: MovieRepository,
): ViewModel(), MovieRepository by repository {

val currentListScreenFlow = savedStateHandle.getMutableStateFlow<ListScreen>( key = "current_list_screen", initialValue = MovieList ) fun goToListScreen(screen: ListScreen) { currentListScreenFlow.value = screen backStackFlow.value = listOf(screen) }
val backStackFlow = savedStateHandle.getMutableStateFlow<List<Screen>>( key = "back_stack", initialValue = listOf(MovieList) ) fun pushScreen(screen: Screen) { backStackFlow.value += screen } fun popScreen() { backStackFlow.value = backStackFlow.value.dropLast(1) } fun doResetDatabase() { viewModelScope.launch(Dispatchers.IO) { repository.resetDatabase() } }
fun deleteSelectedMovies(ids: Set<String>) { viewModelScope.launch { deleteMoviesById(ids) } } fun deleteSelectedActors(ids: Set<String>) { viewModelScope.launch { deleteActorsById(ids) } } fun deleteSelectedRatings(ids: Set<String>) { viewModelScope.launch { deleteRatingsById(ids) } }
companion object { val Factory = viewModelFactory { initializer { val application = checkNotNull(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) val savedStateHandle = this.createSavedStateHandle() MovieViewModel( savedStateHandle = savedStateHandle, repository = MovieDatabaseRepository.create(application) ) } } } }
CHANGED: app/src/main/java/com/androidbyexample/movies/helper/Label.kt
package com.androidbyexample.movies.helper

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, vararg formatArgs: Any, modifier: Modifier = Modifier, ) { Text( // text = stringResource(id = textId), text = stringResource(id = textId, formatArgs = formatArgs), style = MaterialTheme.typography.titleMedium, modifier = modifier .padding(8.dp) .fillMaxWidth() ) }
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, 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. 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)
} } } } } }
ADDED: 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, 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, ) ) }, ) } }
ADDED: app/src/main/java/com/androidbyexample/movies/screens/ActorListUi.kt
package com.androidbyexample.movies.screens

import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable 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.ListScaffold import com.androidbyexample.movies.repository.ActorDto @OptIn(ExperimentalMaterial3Api::class) @Composable fun ActorListUi( actors: List<ActorDto>, modifier: Modifier = Modifier, onActorClicked: (String) -> Unit, onDeleteSelectedActors: (Set<String>) -> Unit, onResetDatabase: () -> Unit, ) { ListScaffold( title = stringResource(R.string.actors), items = actors, onItemClicked = onActorClicked, onDeleteSelectedItems = onDeleteSelectedActors, onResetDatabase = onResetDatabase, itemIconId = R.drawable.person_24, itemContentDescriptionId = R.string.actor, modifier = modifier ) { actor -> Display(text = actor.name) } }
ADDED: app/src/main/java/com/androidbyexample/movies/screens/DetailPlaceholder.kt
package com.androidbyexample.movies.screens

import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource

@OptIn(ExperimentalMaterial3Api::class) @Composable fun DetailPlaceholder( @StringRes messageId: Int, ) { 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 ) TopAppBar( colors = colors, title = {}, ) } ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .fillMaxSize(), contentAlignment = Alignment.Center ) { Text(stringResource(id = messageId)) } } }
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,
onActorClicked: (String) -> Unit, onDeleteSelectedActors: (Set<String>) -> Unit, // onResetDatabase: () -> 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 = onResetDatabase, 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, ) ) }, )
}
ADDED: 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, 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 ) }, ) } }
ADDED: app/src/main/java/com/androidbyexample/movies/screens/RatingListUi.kt
package com.androidbyexample.movies.screens

import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable 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.ListScaffold import com.androidbyexample.movies.repository.RatingDto @OptIn(ExperimentalMaterial3Api::class) @Composable fun RatingListUi( ratings: List<RatingDto>, modifier: Modifier = Modifier, onRatingClicked: (String) -> Unit, onDeleteSelectedRatings: (Set<String>) -> Unit, onResetDatabase: () -> Unit, ) { ListScaffold( title = stringResource(R.string.ratings), items = ratings, onItemClicked = onRatingClicked, onDeleteSelectedItems = onDeleteSelectedRatings, onResetDatabase = onResetDatabase, itemIconId = R.drawable.star_24, itemContentDescriptionId = R.string.rating, modifier = modifier ) { rating -> Display(text = rating.name) } }
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/Screens.kt
package com.androidbyexample.movies.screens

import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.androidbyexample.movies.R
import kotlinx.parcelize.Parcelize

sealed interface Screen: Parcelable
sealed class ListScreen( @DrawableRes val iconId: Int, @StringRes val labelId: Int, ): Screen @Parcelize //data object MovieList: Screen data object MovieList: ListScreen( iconId = R.drawable.movie_24, labelId = R.string.movies, ) @Parcelize data object RatingList: ListScreen( iconId = R.drawable.star_24, labelId = R.string.ratings, ) @Parcelize data object ActorList: ListScreen( iconId = R.drawable.person_24, labelId = R.string.actors, )
@Parcelize data class MovieDisplay(val id: String): Screen @Parcelize data class ActorDisplay(val id: String): Screen @Parcelize data class RatingDisplay(val id: String): Screen
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 listDetailStrategy = rememberListDetailSceneStrategy<Screen>() 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() 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 -> MovieDisplayUi( id = key.id, fetchMovie = viewModel::getMovieWithCast,
// onActorClicked = {}, onActorClicked = { viewModel.pushScreen(ActorDisplay(it)) },
onDeleteSelectedActors = { }, // onResetDatabase = viewModel::doResetDatabase,
) }
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 -> ActorDisplayUi( id = key.id, fetchActor = viewModel::getActorWithFilmography, onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) }, onDeleteSelectedMovies = { }, ) } 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 -> RatingDisplayUi( id = key.id, fetchRating = viewModel::getRatingWithMovies, onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) }, onDeleteSelectedMovies = { }, ) }
} ) }
}
ADDED: app/src/main/res/drawable/person_24.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="960"
    android:viewportHeight="960">
  <path
      android:pathData="M367,433q-47,-47 -47,-113t47,-113q47,-47 113,-47t113,47q47,47 47,113t-47,113q-47,47 -113,47t-113,-47ZM160,800v-112q0,-34 17.5,-62.5T224,582q62,-31 126,-46.5T480,520q66,0 130,15.5T736,582q29,15 46.5,43.5T800,688v112L160,800ZM240,720h480v-32q0,-11 -5.5,-20T700,654q-54,-27 -109,-40.5T480,600q-56,0 -111,13.5T260,654q-9,5 -14.5,14t-5.5,20v32ZM536.5,376.5Q560,353 560,320t-23.5,-56.5Q513,240 480,240t-56.5,23.5Q400,287 400,320t23.5,56.5Q447,400 480,400t56.5,-23.5ZM480,320ZM480,720Z"
      android:fillColor="#e3e3e3"/>
</vector>
ADDED: app/src/main/res/drawable/star_24.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="960"
    android:viewportHeight="960">
  <path
      android:pathData="m354,673 l126,-76 126,77 -33,-144 111,-96 -146,-13 -58,-136 -58,135 -146,13 111,97 -33,143ZM233,840l65,-281L80,370l288,-25 112,-265 112,265 288,25 -218,189 65,281 -247,-149 -247,149ZM480,490Z"
      android:fillColor="#e3e3e3"/>
</vector>
CHANGED: app/src/main/res/values/strings.xml
<resources>
    <string name="app_name">movies</string>
    <string name="movies">Movies</string>
<string name="actors">Actors</string> <string name="ratings">Ratings</string> <string name="actor">Actor</string> <string name="rating">Rating</string> <string name="loading">Loading…</string> <string name="name">Name</string> <string name="movies_starring">Movies Starring %1$s</string> <string name="movies_rated">Movies Rated %1$s</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="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="select_a_movie_to_view">Select a movie to view</string> <string name="select_an_actor_to_view">Select an actor to view</string> <string name="select_a_rating_to_view">Select a rating to view</string>
</resources>
CHANGED: gradle/libs.versions.toml
[versions]
agp = "9.2.1"
coreKtx = "1.19.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0"
kotlin = "2.4.0"
composeBom = "2026.06.01"
navigation = "1.1.4"
material3AdaptiveNav3 = "1.3.0-rc01"
appcompat = "1.7.1"
material = "1.14.0"

room = "2.8.4"
ksp = "2.3.9"

compileSdk = "37"
targetSdk = "37"
minSdk = "24"

javaVersion = "VERSION_11"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }

androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation" }
androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }