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.

Before moving forward, let's update the widget metadata.

  • description adds descriptive text when choosing the widget to place on a home screen. This requires adding the text to strings.xml
  • minWidth and minHeight limits how small the user can size the widget
  • resizeMode specifies which directions the user can resize the widget
  • targetCellWidth and targetCellHeight gives sizing when initially placing the widget. These are multiples of the cell size defined by the home application.
  • widgetCategory specifies if the widget can appear on the home or lock screen (called "keyguard" here)

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.

To do this, we need to:

  • 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)
  • Create a data store that returns the actual data. Its data property returns a new Flow<List<MovieDto>> each time it's read.
  • 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.
  • Get the current state so we can display it.

We need to explicitly request widget updates when the UI changes data. This is done via

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.

The updateAll() call requires a context. We can access the application context (which lives across the lifetime of the application, vs an activity context that only lives while the activity is running) by changing our view model to subclass AndroidViewModel and passing in an Application. AndroidViewModel contains a getApplication() function that allows access when we call updateAll().

This requires that we pass the application when creating our view model in its factory.

The UI for our MovieAppWidget uses a LazyColumn to display our list of movies. When a movie title is clicked, we start our MainActivity and pass the movie id as a parameter.

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. We define the key name as a constant in MainActivity to ensure the name is consistent.

The fun part is now dealing with that intent coming into the activity. The activity may or may not already be running. If you were running the application and went to the home screen, the system may keep the activity instance around until it needs to reclaim its memory. However, it could kill it at any time when it's not the current foreground application. This leads to two ways we must deal with the intent:

  • if the activity is not running, a new instance will be created and the intent passed to onCreate
  • if the activity is running, the intent will be passed to the onNewIntent() function of the existing activity instance

We need to handle the intent in both places, so we create a helper function that tries to pull the movie id out of an intent. If it's not present, we reset the stack to be just MovieList. If it is present, we set the stack to be MovieList followed by MovieDisplay for that movie. This presents the movie to the user and allows them to press back to get to the movie list.

We call handleMovieId() from onCreate and onNewIntent. Note that onNewIntent is passed an Intent as a parameter, while onCreate uses the property intent defined by the activity as the Intent that launched it.

If we run the application now, we'll get some interesting results if we

  1. Click on a movie in the widget (and see the movie displayed)
  2. Go back to home
  3. Click on a different movie in the widget (and see it displayed)
  4. Press back repeatedly

Bad stack!

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 startActvitity 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.

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"
    xmlns:tools="http://schemas.android.com/tools">
<!--             android:launchMode="singleInstance" -->
    <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.MovieUi1"
        tools:targetApi="31">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleInstance" android:theme="@style/Theme.MovieUi1">
<intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> <intent-filter> <action android:name="android.intent.action.VIEW" /> <data android:scheme="https" /> <data android:scheme="http" /> <category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.DEFAULT" /> </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/movie_app_widget_info" /> </receiver>
</application> </manifest>
CHANGED: /app/src/main/java/com/androidbyexample/movie/MainActivity.kt
package com.androidbyexample.movie

import android.content.Intentimport android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.androidbyexample.movie.screens.MovieDisplayimport com.androidbyexample.movie.screens.MovieListimport com.androidbyexample.movie.screens.Ui
import com.androidbyexample.movie.ui.theme.MovieUi1Theme

const val MOVIE_ID_EXTRA = "movieId"
class MainActivity : ComponentActivity() { private val viewModel by viewModels<MovieViewModel> { MovieViewModel.Factory } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
intent?.handleMovieId()
setContent { MovieUi1Theme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { Ui(viewModel) { finish() } } } } }
override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) intent?.handleMovieId() }
private fun Intent.handleMovieId() { val movieId = extras?.getString(MOVIE_ID_EXTRA) if (movieId != null) { viewModel.setScreens(MovieList, MovieDisplay(movieId)) } }
}
CHANGED: /app/src/main/java/com/androidbyexample/movie/MovieViewModel.kt
package com.androidbyexample.movie

import android.app.Applicationimport androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.glance.appwidget.updateAllimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.androidbyexample.movie.glance.MovieAppWidgetimport com.androidbyexample.movie.repository.ActorDto
import com.androidbyexample.movie.repository.MovieDatabaseRepository
import com.androidbyexample.movie.repository.MovieDto
import com.androidbyexample.movie.repository.MovieRepository
import com.androidbyexample.movie.repository.RatingDto
import com.androidbyexample.movie.screens.MovieList
import com.androidbyexample.movie.screens.Screen
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatestimport kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch

@OptIn(FlowPreview::class)
class MovieViewModel( application: Application, private val repository: MovieRepository, //): ViewModel(), MovieRepository by repository {): AndroidViewModel(application), 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 setScreens(vararg screens: Screen) { screenStack = screens.toList() }
fun update(movie: MovieDto) { movieUpdateFlow.value = movie } fun update(actor: ActorDto) { actorUpdateFlow.value = actor } fun update(rating: RatingDto) { ratingUpdateFlow.value = rating } // using a debounced flow as a person-update queue private val movieUpdateFlow = MutableStateFlow<MovieDto?>(null) private val actorUpdateFlow = MutableStateFlow<ActorDto?>(null) private val ratingUpdateFlow = MutableStateFlow<RatingDto?>(null) init {
viewModelScope.launch { repository.moviesFlow.collectLatest { MovieAppWidget().updateAll(getApplication()) } }
viewModelScope.launch { movieUpdateFlow.debounce(500).collect { movie -> movie?.let { repository.upsert(it) } } } viewModelScope.launch { actorUpdateFlow.debounce(500).collect { actor -> actor?.let { repository.upsert(it) } } } viewModelScope.launch { ratingUpdateFlow.debounce(500).collect { rating -> rating?.let { repository.upsert(it) } } } } 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(
application,
MovieDatabaseRepository.create(application) ) as T } } } }
CHANGED: /app/src/main/java/com/androidbyexample/movie/glance/MovieAppWidget.kt
package com.androidbyexample.movie.glance

import android.content.Context
import androidx.compose.ui.unit.dpimport androidx.compose.ui.unit.spimport androidx.datastore.core.DataStoreimport androidx.glance.GlanceId
import androidx.glance.GlanceModifierimport androidx.glance.GlanceThemeimport androidx.glance.action.ActionParametersimport androidx.glance.action.actionParametersOfimport androidx.glance.action.actionStartActivityimport androidx.glance.action.clickableimport androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.appWidgetBackgroundimport androidx.glance.appwidget.lazy.LazyColumnimport androidx.glance.appwidget.lazy.itemsimport androidx.glance.appwidget.provideContent
import androidx.glance.backgroundimport androidx.glance.currentStateimport androidx.glance.layout.fillMaxSizeimport androidx.glance.layout.fillMaxWidthimport androidx.glance.layout.paddingimport androidx.glance.state.GlanceStateDefinitionimport androidx.glance.text.FontWeightimport androidx.glance.text.Text
import androidx.glance.text.TextStyleimport com.androidbyexample.movie.MOVIE_ID_EXTRAimport com.androidbyexample.movie.MainActivityimport com.androidbyexample.movie.repository.MovieDatabaseRepositoryimport com.androidbyexample.movie.repository.MovieDtoimport kotlinx.coroutines.flow.Flowimport java.io.File
private val movieIdKey = ActionParameters.Key<String>(MOVIE_ID_EXTRA)
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 RuntimeException("Should not be used") }}
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 RuntimeException("Should not be used") }}
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(4.dp) .appWidgetBackground() .background(GlanceTheme.colors.background), ) { items(items = movies) { movie -> Text( text = movie.title, modifier = GlanceModifier .padding(4.dp) .fillMaxWidth()
.clickable( actionStartActivity<MainActivity>( actionParametersOf(movieIdKey to movie.id) ) )
, style = TextStyle( fontWeight = FontWeight.Normal, fontSize = 18.sp, ), ) } } }
} } }
CHANGED: /app/src/main/res/values/strings.xml
<resources>
    <string name="app_name">MovieWidget</string>
    <string name="movies">Movies</string>
    <string name="movie">Movie</string>
    <string name="title">Title</string>
    <string name="description">Description</string>
    <string name="reset_database">Reset Database</string>
    <string name="edit">Edit</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>
    <string name="movie_list">Movie List</string></resources>
CHANGED: /app/src/main/res/xml/movie_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/movie_list" android:minWidth="46dp" android:minHeight="46dp" android:resizeMode="horizontal|vertical" android:targetCellWidth="2" android:targetCellHeight="2" android:widgetCategory="home_screen" />