Skip to content

Commit

Permalink
Create an appWidget for Jetnews app using Glance. This serves as an e…
Browse files Browse the repository at this point in the history
…xample for developers migrating to compose also use Glance (a compose like API) for appWidgets.

Change-Id: Iaeff38a44dbfeb0975511c11519211bbc9fb697c
  • Loading branch information
shamalip committed May 10, 2023
1 parent 450fec6 commit ee454d3
Show file tree
Hide file tree
Showing 25 changed files with 747 additions and 13 deletions.
17 changes: 17 additions & 0 deletions JetNews/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,23 @@ on and off, light and dark version in the Android Studio Preview.
[7]: app/src/main/java/com/example/jetnews/ui/interests
[8]: app/src/main/java/com/example/jetnews/ui/interests/SelectTopicButton.kt

### AppWidget powered by Glance

Package [`com.example.jetnews.glance`][0]

This package shows how to use Glance and write compose style code for AppWidgets.

See how to:
* Use `Row`, `Column`, `LazyColumn` to arrange the contents of the UI
* Use an repository from your existing app to load data for the widget and perform updates
* Use a `CoroutineWorker` to periodically load the data to refresh the widget
* Use `androidx.glance:glance-material3` library to create a custom color scheme with `GlanceTheme`
and use dynamic colors when supported
* Tint `Image`s to match the color scheme
* Launch an activity on click

[9]: app/src/main/java/com/example/jetnews/glance

### Data

The data in the sample is static, held in the `com.example.jetnews.data` package.
Expand Down
4 changes: 4 additions & 0 deletions JetNews/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)

implementation(libs.androidx.glance)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)

implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.androidx.lifecycle.livedata.ktx)
Expand Down
20 changes: 19 additions & 1 deletion JetNews/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,28 @@
android:windowSoftInputMode="adjustResize">
<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" />
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:host="developer.android.com"
android:pathPrefix="/jetnews"
android:scheme="https" />
</intent-filter>
</activity>

<receiver
android:name=".glance.JetnewsGlanceAppWidgetReceiver"
android:exported="false"
android:enabled="@bool/glance_appwidget_available">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/jetnews_glance_appwidget_info" />
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import com.example.jetnews.data.AppContainer
import com.example.jetnews.data.AppContainerImpl

class JetnewsApplication : Application() {
companion object {
const val JETNEWS_APP_URI = "https://developer.android.com/jetnews"
}

// AppContainer instance used by the rest of classes to obtain dependencies
lateinit var container: AppContainer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ interface PostsRepository {
*/
fun observeFavorites(): Flow<Set<String>>

/**
* Observe the posts feed.
*/
fun observePostsFeed(): Flow<PostsFeed?>

/**
* Toggle a postId to be a favorite or not.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class BlockingFakePostsRepository : PostsRepository {
// for now, keep the favorites in memory
private val favorites = MutableStateFlow<Set<String>>(setOf())

private val postsFeed = MutableStateFlow<PostsFeed?>(null)

override suspend fun getPost(postId: String?): Result<Post> {
return withContext(Dispatchers.IO) {
val post = posts.allPosts.find { it.id == postId }
Expand All @@ -50,10 +52,12 @@ class BlockingFakePostsRepository : PostsRepository {
}

override suspend fun getPostsFeed(): Result<PostsFeed> {
postsFeed.update { posts }
return Result.Success(posts)
}

override fun observeFavorites(): Flow<Set<String>> = favorites
override fun observePostsFeed(): Flow<PostsFeed?> = postsFeed

override suspend fun toggleFavorite(postId: String) {
favorites.update { it.addOrRemove(postId) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class FakePostsRepository : PostsRepository {
// for now, store these in memory
private val favorites = MutableStateFlow<Set<String>>(setOf())

private val postsFeed = MutableStateFlow<PostsFeed?>(null)

// Used to make suspend functions that read and update state safe to call from any thread

override suspend fun getPost(postId: String?): Result<Post> {
Expand All @@ -56,12 +58,14 @@ class FakePostsRepository : PostsRepository {
if (shouldRandomlyFail()) {
Result.Error(IllegalStateException())
} else {
postsFeed.update { posts }
Result.Success(posts)
}
}
}

override fun observeFavorites(): Flow<Set<String>> = favorites
override fun observePostsFeed(): Flow<PostsFeed?> = postsFeed

override suspend fun toggleFavorite(postId: String) {
favorites.update {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.jetnews.glance

import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import com.example.jetnews.glance.ui.JetnewsGlanceAppWidget

class JetnewsGlanceAppWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = JetnewsGlanceAppWidget()
}
55 changes: 55 additions & 0 deletions JetNews/app/src/main/java/com/example/jetnews/glance/ui/Divider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetnews.glance.ui

import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.background
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.unit.ColorProvider
import com.example.jetnews.glance.ui.theme.JetnewsGlanceColorScheme

/**
* A thin line that groups content in lists and layouts.
*
* @param thickness thickness in dp of this divider line.
* @param color color of this divider line.
*/
@Composable
fun Divider(
thickness: Dp = DividerDefaults.Thickness,
color: ColorProvider = DividerDefaults.color
) {
Spacer(
modifier = GlanceModifier
.fillMaxWidth()
.height(thickness)
.background(color)
)
}

/** Default values for [Divider] */
object DividerDefaults {
/** Default thickness of a divider. */
val Thickness: Dp = 1.dp

/** Default color of a divider. */
val color: ColorProvider @Composable get() = JetnewsGlanceColorScheme.outlineVariant
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.jetnews.glance.ui


import android.content.Context
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.unit.dp
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.itemsIndexed
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import com.example.jetnews.JetnewsApplication
import com.example.jetnews.R
import com.example.jetnews.data.successOr
import com.example.jetnews.glance.ui.theme.JetnewsGlanceColorScheme
import com.example.jetnews.model.Post
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class JetnewsGlanceAppWidget : GlanceAppWidget() {
override val sizeMode: SizeMode = SizeMode.Exact

override suspend fun provideGlance(context: Context, id: GlanceId) {
val application = context.applicationContext as JetnewsApplication
val postsRepository = application.container.postsRepository

// Load data needed to render the composable.
// The repository can return cached results here if it already has fresh data.
val initialPostsFeed = withContext(Dispatchers.IO) {
postsRepository.getPostsFeed().successOr(null)
}
val initialBookmarks: Set<String> = withContext(Dispatchers.IO) {
postsRepository.observeFavorites().first()
}

provideContent {
val scope = rememberCoroutineScope()
val bookmarks by postsRepository.observeFavorites().collectAsState(initialBookmarks)
val postsFeed by postsRepository.observePostsFeed().collectAsState(initialPostsFeed)
val recommendedTopPosts =
postsFeed?.let { listOf(it.highlightedPost) + it.recommendedPosts } ?: emptyList()

// Provide a custom color scheme if the SDK version doesn't support dynamic colors.
GlanceTheme(
colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
GlanceTheme.colors
} else {
JetnewsGlanceColorScheme.colors
}
) {
JetnewsContent(
posts = recommendedTopPosts,
bookmarks = bookmarks,
onToggleBookmark = { scope.launch { postsRepository.toggleFavorite(it) } }
)
}
}
}

@Composable
private fun JetnewsContent(
posts: List<Post>,
bookmarks: Set<String>?,
onToggleBookmark: (String) -> Unit
) {
Column(
modifier = GlanceModifier
.background(GlanceTheme.colors.surface)
.cornerRadius(24.dp)
) {
Header(modifier = GlanceModifier.fillMaxWidth())
key(LocalSize.current) {
Body(
modifier = GlanceModifier.fillMaxWidth(),
posts = posts,
bookmarks = bookmarks ?: setOf(),
onToggleBookmark = onToggleBookmark
)
}
}
}

@Composable
fun Header(modifier: GlanceModifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.padding(horizontal = 10.dp, vertical = 20.dp)
) {
val context = LocalContext.current
Image(
provider = ImageProvider(R.drawable.ic_jetnews_logo),
colorFilter = ColorFilter.tint(GlanceTheme.colors.primary),
contentDescription = null,
modifier = GlanceModifier.size(24.dp)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Image(
contentDescription = context.getString(R.string.app_name),
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
provider = ImageProvider(R.drawable.ic_jetnews_wordmark)
)
}
}

@Composable
fun Body(
modifier: GlanceModifier,
posts: List<Post>,
bookmarks: Set<String>,
onToggleBookmark: (String) -> Unit,
) {
val postLayout = LocalSize.current.toPostLayout()
LazyColumn(modifier = modifier.background(GlanceTheme.colors.background)) {
itemsIndexed(posts) { index, post ->
Column(modifier = GlanceModifier.padding(horizontal = 14.dp)) {
Post(
post = post,
bookmarks = bookmarks,
onToggleBookmark = onToggleBookmark,
modifier = GlanceModifier.fillMaxWidth().padding(15.dp),
postLayout = postLayout,
)
if (index < posts.lastIndex) {
Divider()
}
}
}
}
}
}
Loading

0 comments on commit ee454d3

Please sign in to comment.