Movies Widget
Widget UI and Data
Let's make this widget actually do something. We'll fetch the list of movies from the database and display it on the widget. When the user selects a movie, they'll be taken to its display page in the app.
More widget metadata
Before moving forward, let's update the widget metadata
show in full file app/src/main/res/xml/movies_app_widget_info.xml
// ...
<appwidget-provider
xmlns:android="http://schemas.android.com/apk/res/android"
// android:initialLayout="@layout/glance_default_loading_layout" />
android:initialLayout="@layout/glance_default_loading_layout"
android:description="@string/movies"
android:minWidth="46dp"
android:minHeight="46dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:widgetCategory="home_screen" />
descriptionadds descriptive text when choosing the widget to place on a home screen. This requires adding the text to strings.xmlminWidthandminHeightlimits how small the user can size the widgetresizeModespecifies which directions the user can resize the widgettargetCellWidthandtargetCellHeightgives sizing when initially placing the widget. These are multiples of the cell size defined by the home application.widgetCategoryspecifies if the widget can appear on the home or lock screen (called "keyguard" here)
Widget data
Widgets run in a different process than our application. The MovieAppWidget runs in our
application's process to create a set of Remote Views that are sent to the widget to be
rendered. We need to set it up to fetch data when first creating the widget and when the widget is
updated.
By default, GlanceAppWidget is set up to read widget state from a preference store. This is a
file that's private to your application, and is normally used to store application settings
between runs. Here it's used for the application to write widget state updates. If you only
have minor data to update, this is fine, but a movie list is typically too much data to store
in a preference store (and the data would have to be read from the database, written to
the file, then read back from the file to perform the widget update).
Instead, we'll override the update data handling.
All classes that extend GlanceAppWidget contain a GlanceStateDefinition instance that
provides a DataStore that the widget can use to get its data. We'll use this data store to
fetch the initial list of movies from the database. Glance gets the data property from the
data store, a Flow of our movie list in this case.
Whenever the widget is told to update (we'll see that later), Glance reads data again and updates
state.
Normally, if we use a DataStore, we read data once to get the Flow of updates, and then
collect from that Flow to get changes. However, the MovieAppWidget wants an explicit update
notification, telling it when to update its data. We'll ask for a new Flow each time from
our repository to get the movie list.
Create a data store that returns the actual data. Its data property returns a new
Flow<List<MovieDto>> each time it's read.
show in full file app/src/main/java/com/androidbyexample/movies/glance/MovieDataStore.kt
// ...
import kotlinx.coroutines.flow.Flow
class MovieDataStore(
context: Context,
): DataStore<List<MovieDto>> {
val repository = MovieDatabaseRepository.create(context)
override val data: Flow<List<MovieDto>>
get() = repository.moviesFlow
override suspend fun updateData(
transform: suspend (t: List<MovieDto>) -> List<MovieDto>
): List<MovieDto> {
throw NotImplementedError("not used")
}
}
Then we create a state definition class. This is just a factory for our
data store. We don't need to define the getLocation function; it's not used (and normally
returns a File indicating where the data is stored)
show in full file app/src/main/java/com/androidbyexample/movies/glance/MovieGlanceStateDefinition.kt
// ...
import java.io.File
class MovieGlanceStateDefinition: GlanceStateDefinition<List<MovieDto>> {
override suspend fun getDataStore(
context: Context,
fileKey: String
): DataStore<List<MovieDto>> = MovieDataStore(context)
override fun getLocation(
context: Context,
fileKey: String
): File {
throw NotImplementedError("not used")
}
}
Add a state definition property in our MovieAppWidget so it knows how to create its data store
(and then get its data). Because the widget doesn't update the data, we don't implement the
updateData function.
show in full file app/src/main/java/com/androidbyexample/movies/glance/MovieAppWidget.kt
// ...
class MovieAppWidget : GlanceAppWidget() {
override val stateDefinition = MovieGlanceStateDefinition()
override suspend fun provideGlance(context: Context, id: GlanceId) {
// ...
}
Widget user interface
Before we create our user interface, we need to define a key name that will be used to pass the
movie id from the widget to the activity. The Intent passed from the widget to the activity
contains a map of data called "extras", so we define in MainActivity
show in full file app/src/main/java/com/androidbyexample/movies/MainActivity.kt
// ...
import com.androidbyexample.movies.ui.theme.MoviesTheme
const val MOVIE_ID_EXTRA = "movieId"
class MainActivity : ComponentActivity() {
// ...
Now our widget user interface
Note
Be sure to select the "glance" versions of the composables used!
Get the current state so we can display it, and use a LazyColumn to create the widget ui.
show in full file app/src/main/java/com/androidbyexample/movies/glance/MovieAppWidget.kt
// ...
import com.androidbyexample.movies.repository.MovieDto
private val movieIdKey = ActionParameters.Key<String>(MOVIE_ID_EXTRA)
class MovieAppWidget : GlanceAppWidget() {
// ...
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// Text(text = "Widget!")
val movies = currentState<List<MovieDto>>()
GlanceTheme {
LazyColumn(
modifier = GlanceModifier
.fillMaxSize()
.padding(8.dp)
.appWidgetBackground()
.background(GlanceTheme.colors.background)
) {
item {
Text(text = context.getString(R.string.movies))
}
items(items = movies) { movie ->
Text(
text = movie.title,
modifier = GlanceModifier
.padding(8.dp)
.fillMaxWidth()
.clickable(
actionStartActivity<MainActivity>(
actionParametersOf(movieIdKey to movie.id)
)
),
style = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 18.sp,
)
)
}
}
}
}
}
}
When a movie title is clicked, we send an Intent that requests to start our MainActivity
and pass the movie id as an extra.
Note that actionStartActivity creates a lambda for us, so we pass it in parentheses to
clickable rather than put it in a lambda. If we had instead written
.clickable {
actionStartActivity<MainActivity>(
actionParametersOf(movieIdKey to movie.id)
)
}
we'd be telling it to call actionStartActivity to create the lambda when the button is clicked.
We need to create that lambda and use it as the function called when the button is clicked.
The created lambda creates an explicit Intent targeting our activity, and uses the
actionParametersOf function to add key and value pairs to the extras map of that intent.
The name specified in the movieIdKey must match the name we use to retrieve the data from the
intent in MainActivity (which is why we defined that name inside MainActivity)
Updating the widget
We need to explicitly request widget updates when we change the data. This is done by calling
MovieAppWidget().updateAll(context)
Note
This will update all instances of the movie widget that the user has placed on their home screens. It's possible to update just a specific instance if you'd like, but we won't go into that detail here. (Think as an example, different instances of a picture-frame widget that host different images - you click on one to take you to the app to select an image, and it updates only that instance of the widget)
Ideally, we want to make this update call in as few places as possible, while ensuring we cover
all cases. In our application, this is simple - we can watch the movie flow for emissions and
call update. For this purpose, we start a coroutine when the view model is initialized and
collect the movie flow. We assume that the only way the data is being updated is via our app;
if there are other ways to update the data, we'd have to be sure to call updateAll() there as
well.
We need to pass a Context to updateAll(). We currently don't have a context in the view model,
so we will change the view model to extend AndroidViewModel instead. This requires we pass
an application instance to it.
Note
The Application object is a singleton that exists for the lifetime of the application,
and is a Context that can be used to access application resources and interact with the
Android system. A default instance is created for all applications, but you can create a
subclass of Application and specify it using the android:name parameter of the application
tag in AndroidManifest.xml. You will most likely only need this for certain
dependency-injection libraries such as
Hilt.
Convert the view model to an AndroidViewModel and update its factory
show in full file app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
// ...
class MovieViewModel(
private val savedStateHandle: SavedStateHandle,
application: Application,
private val repository: MovieRepository,
//): ViewModel(), MovieRepository by repository {
//
): AndroidViewModel(application), MovieRepository by repository {
val currentListScreenFlow = savedStateHandle.getMutableStateFlow<ListScreen>(
key = "current_list_screen",
// ...
companion object {
val Factory = viewModelFactory {
initializer {
val application =
checkNotNull(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY])
val savedStateHandle = this.createSavedStateHandle()
MovieViewModel(
savedStateHandle = savedStateHandle,
application = application,
repository = MovieDatabaseRepository.create(application)
)
}
}
}
}
Now we can watch for changes and update the widget
show in full file app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
// ...
class MovieViewModel(
// ...
): AndroidViewModel(application), MovieRepository by repository {
// ...
)
init {
viewModelScope.launch {
repository.moviesFlow.collectLatest {
MovieAppWidget().updateAll(getApplication())
}
}
}
fun goToListScreen(screen: ListScreen) {
// ...
}
Jumping directly to the movie display
When the user selects a movie from the list in the widget, we need to show the movie display screen. But what happens when the user presses back? It would be nice if we could go to the movie list screen so it feels the same as when the user runs the app directly.
We need to consider what happens when the application has been hibernated, vs when it has.
As we've discussed, if the user goes to the home screen or another app, Android may hibernate
the app, saving its data so it can recreate it. In our application, the part most sensitive
to this is the back stack. We're using a SavedStateHandle to manage its data, which includes
reloading where the user was when they left the app.
We want to make sure that the widget can override that state. The trick is that when the app is
hibernated, Android saves a copy of the Intent that started it. When the app is restored,
a new Activity instance is created and that Intent is available inside onCreate().
If the Activity is being run from scratch, we need to look at that Intent, and if it contains
a movie id, set up the back stack to contain the MovieList followed by a MovieDisplay for that
movie. If the Activity is being restored from hibernation, we should ignore the Intent
so we don't overwrite the hibernated state with the app's original starting state.
When being restored, not only is onCreate() called with the initial Intent set, but any new
intent being passed (like the intent from the widget) is passed to onNewIntent(). If we get
a call to onNewIntent() with an Intent that contains a movie id, we always want to override
the back stack.
We need a way to determine if the back stack has been loaded from a hibernated state. If we add a "loading" screen, we can check if the stack only contains that loading screen.
show in full file app/src/main/java/com/androidbyexample/movies/screens/Screens.kt
// ...
)
val loadingOnly = listOf(Loading)
@Parcelize
data object Loading: Screen
@Parcelize
// ...
show in full file app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
// ...
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
// ...
) {
// ...
NavigationSuiteScaffold(
// ...
) {
NavDisplay(
// ...
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider {
entry<Loading> {
DetailPlaceholder(messageId = R.string.loading)
}
entry<MovieList>(
metadata = ListDetailSceneStrategy.listPane(
// ...
}
)
}
}
We need to add support for this in the view model and activity. First, the view model:
show in full file app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
// ...
class MovieViewModel(
// ...
): AndroidViewModel(application), MovieRepository by repository {
// ...
val backStackFlow = savedStateHandle.getMutableStateFlow<List<Screen>>(
key = "back_stack",
// initialValue = listOf(MovieList)
initialValue = loadingOnly
)
fun setInitialScreens(movieId: String?) {
// only change the back stack on start if there wasn't a hibernated value
if (backStackFlow.value == loadingOnly) {
backStackFlow.value =
movieId
?.let { listOf(MovieList, MovieDisplay(it)) }
?: listOf(MovieList)
}
}
fun setTargetScreens(movieId: String?) {
// always change if we have a target screen
// (will only happen when onNewIntent is called)
movieId?.let { id ->
backStackFlow.value = listOf(MovieList, MovieDisplay(id))
}
}
fun pushScreen(screen: Screen) {
// ...
}
We can call these functions from onCreate() and onNewIntent()
show in full file app/src/main/java/com/androidbyexample/movies/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MovieViewModel> { MovieViewModel.Factory }
private val Intent.movieId: String?
get() = extras?.getString(MOVIE_ID_EXTRA)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.setInitialScreens(intent.movieId)
enableEdgeToEdge()
// ...
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
viewModel.setTargetScreens(intent.movieId)
}
}
If we run the application now, we'll get some interesting results if we
- Click on a movie in the widget (and see the movie displayed)
- Go back to home
- Click on a different movie in the widget (and see it displayed)
- Press back repeatedly

The problem is that each time we start the activity from the widget, a new instance is pushed
on the stack for the application process. We now have a stack of activities, managed by the
system, each of which contains a stack of screens. Originally, Android applications used an
Actitity for each screen, calling startActivity() to push new instances on the system-managed
stacks. Over time, we evolved to using a single Activity approach.
To fix this, we set the launch mode for our MainActivity to singleInstance, which ensures we
only ever have one instance of the activity.
show in full file app/src/main/AndroidManifest.xml
// ...
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
// ...
<application
// ...
android:theme="@style/Theme.Movies">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleInstance"
android:theme="@style/Theme.Movies"
android:windowSoftInputMode="adjustResize">
// ...
</activity>
// ...
</application>
// ...
</manifest>
All code changes
CHANGED: app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Movies">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleInstance"
android:theme="@style/Theme.Movies"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver android:name=".glance.MovieAppWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/movies_app_widget_info" />
</receiver>
</application>
</manifest>
CHANGED: app/src/main/java/com/androidbyexample/movies/MainActivity.kt
package com.androidbyexample.movies
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import com.androidbyexample.movies.screens.Ui
import com.androidbyexample.movies.ui.theme.MoviesTheme
const val MOVIE_ID_EXTRA = "movieId"
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MovieViewModel> { MovieViewModel.Factory }
private val Intent.movieId: String?
get() = extras?.getString(MOVIE_ID_EXTRA)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.setInitialScreens(intent.movieId)
enableEdgeToEdge()
setContent {
MoviesTheme {
Ui(viewModel = viewModel)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
viewModel.setTargetScreens(intent.movieId)
}
}
CHANGED: app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
package com.androidbyexample.movies
import android.app.Application
import androidx.glance.appwidget.updateAll
import androidx.lifecycle.AndroidViewModel
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.glance.MovieAppWidget
import com.androidbyexample.movies.repository.ActorDto
import com.androidbyexample.movies.repository.MovieDatabaseRepository
import com.androidbyexample.movies.repository.MovieDto
import com.androidbyexample.movies.repository.MovieRepository
import com.androidbyexample.movies.repository.RatingDto
import com.androidbyexample.movies.screens.ListScreen
import com.androidbyexample.movies.screens.MovieDisplay
import com.androidbyexample.movies.screens.MovieList
import com.androidbyexample.movies.screens.Screen
import com.androidbyexample.movies.screens.loadingOnly
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class MovieViewModel(
private val savedStateHandle: SavedStateHandle,
application: Application,
private val repository: MovieRepository,
//): ViewModel(), MovieRepository by repository {
//
): AndroidViewModel(application), MovieRepository by repository {
val currentListScreenFlow = savedStateHandle.getMutableStateFlow<ListScreen>(
key = "current_list_screen",
initialValue = MovieList
)
init {
viewModelScope.launch {
repository.moviesFlow.collectLatest {
MovieAppWidget().updateAll(getApplication())
}
}
}
fun goToListScreen(screen: ListScreen) {
currentListScreenFlow.value = screen
backStackFlow.value = listOf(screen)
}
val backStackFlow = savedStateHandle.getMutableStateFlow<List<Screen>>(
key = "back_stack",
// initialValue = listOf(MovieList)
initialValue = loadingOnly
)
fun setInitialScreens(movieId: String?) {
// only change the back stack on start if there wasn't a hibernated value
if (backStackFlow.value == loadingOnly) {
backStackFlow.value =
movieId
?.let { listOf(MovieList, MovieDisplay(it)) }
?: listOf(MovieList)
}
}
fun setTargetScreens(movieId: String?) {
// always change if we have a target screen
// (will only happen when onNewIntent is called)
movieId?.let { id ->
backStackFlow.value = listOf(MovieList, MovieDisplay(id))
}
}
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)
}
}
fun update(movie: MovieDto) {
viewModelScope.launch {
upsert(movie)
}
}
fun update(actor: ActorDto) {
viewModelScope.launch {
upsert(actor)
}
}
fun update(rating: RatingDto) {
viewModelScope.launch {
upsert(rating)
}
}
companion object {
val Factory = viewModelFactory {
initializer {
val application =
checkNotNull(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY])
val savedStateHandle = this.createSavedStateHandle()
MovieViewModel(
savedStateHandle = savedStateHandle,
application = application,
repository = MovieDatabaseRepository.create(application)
)
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/movies/glance/MovieAppWidget.kt
package com.androidbyexample.movies.glance
import android.content.Context
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import com.androidbyexample.movies.MOVIE_ID_EXTRA
import com.androidbyexample.movies.MainActivity
import com.androidbyexample.movies.R
import com.androidbyexample.movies.repository.MovieDto
private val movieIdKey = ActionParameters.Key<String>(MOVIE_ID_EXTRA)
class MovieAppWidget : GlanceAppWidget() {
override val stateDefinition = MovieGlanceStateDefinition()
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// Text(text = "Widget!")
val movies = currentState<List<MovieDto>>()
GlanceTheme {
LazyColumn(
modifier = GlanceModifier
.fillMaxSize()
.padding(8.dp)
.appWidgetBackground()
.background(GlanceTheme.colors.background)
) {
item {
Text(text = context.getString(R.string.movies))
}
items(items = movies) { movie ->
Text(
text = movie.title,
modifier = GlanceModifier
.padding(8.dp)
.fillMaxWidth()
.clickable(
actionStartActivity<MainActivity>(
actionParametersOf(movieIdKey to movie.id)
)
),
style = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 18.sp,
)
)
}
}
}
}
}
}
ADDED: app/src/main/java/com/androidbyexample/movies/glance/MovieDataStore.kt
package com.androidbyexample.movies.glance
import android.content.Context
import androidx.datastore.core.DataStore
import com.androidbyexample.movies.repository.MovieDatabaseRepository
import com.androidbyexample.movies.repository.MovieDto
import kotlinx.coroutines.flow.Flow
class MovieDataStore(
context: Context,
): DataStore<List<MovieDto>> {
val repository = MovieDatabaseRepository.create(context)
override val data: Flow<List<MovieDto>>
get() = repository.moviesFlow
override suspend fun updateData(
transform: suspend (t: List<MovieDto>) -> List<MovieDto>
): List<MovieDto> {
throw NotImplementedError("not used")
}
}
ADDED: app/src/main/java/com/androidbyexample/movies/glance/MovieGlanceStateDefinition.kt
package com.androidbyexample.movies.glance
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.glance.state.GlanceStateDefinition
import com.androidbyexample.movies.repository.MovieDto
import java.io.File
class MovieGlanceStateDefinition: GlanceStateDefinition<List<MovieDto>> {
override suspend fun getDataStore(
context: Context,
fileKey: String
): DataStore<List<MovieDto>> = MovieDataStore(context)
override fun getLocation(
context: Context,
fileKey: String
): File {
throw NotImplementedError("not used")
}
}
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: ListScreen(
iconId = R.drawable.movie_24,
labelId = R.string.movies,
)
val loadingOnly = listOf(Loading)
@Parcelize
data object Loading: Screen
@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
@Parcelize
data class MovieEdit(val id: String): Screen
@Parcelize
data class ActorEdit(val id: String): Screen
@Parcelize
data class RatingEdit(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.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.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
import com.androidbyexample.movies.repository.ActorDto
import com.androidbyexample.movies.repository.MovieDto
import com.androidbyexample.movies.repository.RatingDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
viewModel: MovieViewModel,
) {
val windowAdaptiveInfo = currentWindowAdaptiveInfoV2()
val directive = remember(windowAdaptiveInfo) {
calculatePaneScaffoldDirective(windowAdaptiveInfo)
.copy(horizontalPartitionSpacerSize = 0.dp)
}
val listDetailStrategy = rememberListDetailSceneStrategy<Screen>(directive = directive)
val backStack by viewModel.backStackFlow.collectAsStateWithLifecycle(listOf(MovieList))
val currentListScreen by viewModel.currentListScreenFlow.collectAsStateWithLifecycle(MovieList)
val listScreens = remember { listOf(RatingList, MovieList, ActorList) }
NavigationSuiteScaffold(
navigationSuiteItems = {
listScreens.forEach { target ->
item(
icon = {
Icon(
painter = painterResource(target.iconId),
contentDescription = stringResource(target.labelId)
)
},
label = { Text(stringResource(target.labelId)) },
selected = currentListScreen == target,
onClick = {
viewModel.goToListScreen(target)
}
)
}
}
) {
NavDisplay(
backStack = backStack,
onBack = viewModel::popScreen,
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider {
entry<Loading> {
DetailPlaceholder(messageId = R.string.loading)
}
entry<MovieList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { DetailPlaceholder(R.string.select_a_movie_to_view) }
)
) {
val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
MovieListUi(
movies = movies,
onMovieClicked = { movieId ->
viewModel.pushScreen(MovieDisplay(movieId))
},
onDeleteSelectedMovies = { ids ->
viewModel.deleteSelectedMovies(ids)
},
onResetDatabase = viewModel::doResetDatabase,
)
}
entry<MovieDisplay>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
val movieWithCast by
viewModel
.getMovieWithCastFlow(key.id)
.collectAsStateWithLifecycle(null)
MovieDisplayUi(
movieWithCast = movieWithCast,
onActorClicked = { viewModel.pushScreen(ActorDisplay(it)) },
onEdit = { viewModel.pushScreen(MovieEdit(it)) },
onDeleteSelectedActors = viewModel::deleteSelectedActors,
)
}
entry<MovieEdit> { screen ->
var movie by remember { mutableStateOf<MovieDto?>(null) }
LaunchedEffect(key1 = screen.id) {
withContext(Dispatchers.IO) {
movie = viewModel.getMovie(screen.id)
}
}
MovieEditUi(
movie = movie,
onMovieChange = viewModel::update,
)
}
entry<ActorList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { DetailPlaceholder(R.string.select_an_actor_to_view) }
)
) {
val actors by viewModel.actorsFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
ActorListUi(
actors = actors,
onActorClicked = { actorId ->
viewModel.pushScreen(ActorDisplay(actorId))
},
onDeleteSelectedActors = { ids ->
viewModel.deleteSelectedActors(ids)
},
onResetDatabase = viewModel::doResetDatabase,
)
}
entry<ActorDisplay>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
val actorWithFilmography by
viewModel
.getActorWithFilmographyFlow(key.id)
.collectAsStateWithLifecycle(null)
ActorDisplayUi(
actorWithFilmography = actorWithFilmography,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) },
onEdit = { viewModel.pushScreen(ActorEdit(it)) },
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
)
}
entry<ActorEdit> { screen ->
var actor by remember { mutableStateOf<ActorDto?>(null) }
LaunchedEffect(key1 = screen.id) {
withContext(Dispatchers.IO) {
actor = viewModel.getActor(screen.id)
}
}
ActorEditUi(
actor = actor,
onActorChange = viewModel::update,
)
}
entry< RatingList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { DetailPlaceholder(R.string.select_a_rating_to_view) }
)
) {
val ratings by viewModel.ratingsFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
RatingListUi(
ratings = ratings,
onRatingClicked = { ratingId ->
viewModel.pushScreen(RatingDisplay(ratingId))
},
onDeleteSelectedRatings = { ids ->
viewModel.deleteSelectedRatings(ids)
},
onResetDatabase = viewModel::doResetDatabase,
)
}
entry<RatingDisplay>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
val ratingWithMovies by
viewModel
.getRatingWithMoviesFlow(key.id)
.collectAsStateWithLifecycle(null)
RatingDisplayUi(
ratingWithMovies = ratingWithMovies,
onMovieClicked = { viewModel.pushScreen(MovieDisplay(it)) },
onEdit = { viewModel.pushScreen(RatingEdit(it)) },
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
)
}
entry<RatingEdit> { screen ->
var rating by remember { mutableStateOf<RatingDto?>(null) }
LaunchedEffect(key1 = screen.id) {
withContext(Dispatchers.IO) {
rating = viewModel.getRating(screen.id)
}
}
RatingEditUi(
rating = rating,
onRatingChange = viewModel::update,
)
}
}
)
}
}
CHANGED: app/src/main/res/xml/movies_app_widget_info.xml
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider
xmlns:android="http://schemas.android.com/apk/res/android"
// android:initialLayout="@layout/glance_default_loading_layout" />
android:initialLayout="@layout/glance_default_loading_layout"
android:description="@string/movies"
android:minWidth="46dp"
android:minHeight="46dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:widgetCategory="home_screen" />