diff --git a/Jetsnack/app/build.gradle.kts b/Jetsnack/app/build.gradle.kts
index e542dc2e47..ba7fe9e017 100644
--- a/Jetsnack/app/build.gradle.kts
+++ b/Jetsnack/app/build.gradle.kts
@@ -23,13 +23,22 @@ plugins {
}
android {
- compileSdk = libs.versions.compileSdk.get().toInt()
+ compileSdk =
+ libs.versions.compileSdk
+ .get()
+ .toInt()
namespace = "com.example.jetsnack"
defaultConfig {
applicationId = "com.example.jetsnack"
- minSdk = libs.versions.minSdk.get().toInt()
- targetSdk = libs.versions.targetSdk.get().toInt()
+ minSdk =
+ libs.versions.minSdk
+ .get()
+ .toInt()
+ targetSdk =
+ libs.versions.targetSdk
+ .get()
+ .toInt()
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -131,4 +140,7 @@ dependencies {
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+
+ implementation(libs.androidx.glance.appwidget)
+ implementation(libs.androidx.glance.preview)
}
diff --git a/Jetsnack/app/src/main/AndroidManifest.xml b/Jetsnack/app/src/main/AndroidManifest.xml
index a494a7af72..8efeba6f4a 100644
--- a/Jetsnack/app/src/main/AndroidManifest.xml
+++ b/Jetsnack/app/src/main/AndroidManifest.xml
@@ -25,6 +25,17 @@
android:theme="@style/Theme.Jetsnack"
>
+
+
+
+
+
+
= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ lifecycleScope.launch(Dispatchers.Default) {
+ setWidgetPreviews()
+ }
+ }
setContent { JetsnackApp() }
}
+
+ @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+ suspend fun setWidgetPreviews() {
+ val receiver = RecentOrdersWidgetReceiver::class
+ val installedProviders = getSystemService(AppWidgetManager::class.java).installedProviders
+ val providerInfo = installedProviders.firstOrNull {
+ it.provider.className ==
+ receiver.qualifiedName
+ }
+ providerInfo?.generatedPreviewCategories.takeIf { it == 0 }?.let {
+ // Set previews if this provider if unset
+ GlanceAppWidgetManager(this).setWidgetPreviews(receiver)
+ }
+ }
}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt
index c22cf1d8eb..00205d573a 100644
--- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 The Android Open Source Project
+ * Copyright 2020-2025 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.
@@ -76,6 +76,7 @@ import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDeepLink
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
+import androidx.navigation.navDeepLink
import com.example.jetsnack.R
import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope
import com.example.jetsnack.ui.components.JetsnackSurface
@@ -140,7 +141,12 @@ fun NavGraphBuilder.addHomeGraph(onSnackSelected: (Long, String, NavBackStackEnt
modifier,
)
}
- composable(HomeSections.CART.route) { from ->
+ composable(
+ HomeSections.CART.route,
+ deepLinks = listOf(
+ navDeepLink { uriPattern = "https://jetsnack.example.com/home/cart" },
+ ),
+ ) { from ->
Cart(
onSnackClick = { id, origin -> onSnackSelected(id, origin, from) },
modifier,
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/ActionDemonstrationActivity.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/ActionDemonstrationActivity.kt
new file mode 100644
index 0000000000..4578adde0e
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/ActionDemonstrationActivity.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2023-2025 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.jetsnack.widget
+
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.glance.action.ActionParameters
+
+internal val ActionSourceMessageKey = ActionParameters.Key("actionSourceMessageKey")
+
+/**
+ * Activity that is launched on clicks from different parts of sample widgets. Displays string
+ * describing source of the click.
+ */
+class ActionDemonstrationActivity : ComponentActivity() {
+
+ override fun onResume() {
+ super.onResume()
+ setContent {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ val source = intent.getStringExtra(ActionSourceMessageKey.name) ?: "Unknown"
+ Text("Launched from $source")
+ }
+ }
+ }
+}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/RecentOrdersWidget.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/RecentOrdersWidget.kt
new file mode 100644
index 0000000000..6e8ab809cd
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/RecentOrdersWidget.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2025 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.jetsnack.widget
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.glance.GlanceId
+import androidx.glance.GlanceTheme
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.appwidget.AppWidgetId
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.provideContent
+import com.example.jetsnack.R
+import com.example.jetsnack.ui.MainActivity
+import com.example.jetsnack.widget.data.RecentOrdersDataRepository
+import com.example.jetsnack.widget.data.RecentOrdersDataRepository.Companion.getImageTextListDataRepo
+import com.example.jetsnack.widget.layout.ImageTextListItemData
+import com.example.jetsnack.widget.layout.ImageTextListLayout
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class RecentOrdersWidget : GlanceAppWidget() {
+ // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in
+ // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each
+ // supported size to be held in the widget host's memory.
+ override val sizeMode: SizeMode = SizeMode.Exact
+
+ override val previewSizeMode = SizeMode.Responsive(
+ setOf(
+ DpSize(256.dp, 115.dp), // 4x2 cell min size
+ DpSize(260.dp, 180.dp), // Medium width layout, height with header
+ ),
+ )
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val repo = getImageTextListDataRepo(id)
+
+ val initialItems = withContext(Dispatchers.Default) {
+ repo.load(context)
+ }
+
+ provideContent {
+ GlanceTheme {
+ val items by repo.data().collectAsState(initial = initialItems)
+
+ key(LocalSize.current) {
+ WidgetContent(
+ items = items,
+ shoppingCartActionIntent = Intent(
+ context.applicationContext,
+ MainActivity::class.java,
+ )
+ .setAction(Intent.ACTION_VIEW)
+ .setFlags(
+ Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK,
+ )
+ .setData("https://jetsnack.example.com/home/cart".toUri()),
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun WidgetContent(items: List, shoppingCartActionIntent: Intent) {
+ val context = LocalContext.current
+
+ ImageTextListLayout(
+ items = items,
+ title = context.getString(R.string.widget_title),
+ titleIconRes = R.drawable.widget_logo,
+ titleBarActionIconRes = R.drawable.shopping_cart,
+ titleBarActionIconContentDescription = context.getString(
+ R.string.shopping_cart_button_label,
+ ),
+ titleBarAction = actionStartActivity(shoppingCartActionIntent),
+ shoppingCartActionIntent = shoppingCartActionIntent,
+ )
+ }
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) {
+ val repo = RecentOrdersDataRepository()
+ val items = repo.load(context)
+
+ provideContent {
+ GlanceTheme {
+ WidgetContent(
+ items = items,
+ shoppingCartActionIntent = Intent(),
+ )
+ }
+ }
+ }
+}
+
+class RecentOrdersWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget = RecentOrdersWidget()
+
+ @SuppressLint("RestrictedApi")
+ override fun onDeleted(context: Context, appWidgetIds: IntArray) {
+ appWidgetIds.forEach {
+ RecentOrdersDataRepository.cleanUp(AppWidgetId(it))
+ }
+ super.onDeleted(context, appWidgetIds)
+ }
+}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/data/RecentOrdersDataRepository.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/data/RecentOrdersDataRepository.kt
new file mode 100644
index 0000000000..f5eb2954d1
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/data/RecentOrdersDataRepository.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2025 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.jetsnack.widget.data
+
+import android.content.Context
+import androidx.annotation.DrawableRes
+import androidx.core.content.ContextCompat.getString
+import androidx.glance.GlanceId
+import com.example.jetsnack.R
+import com.example.jetsnack.model.Snack
+import com.example.jetsnack.model.snacks
+import com.example.jetsnack.widget.layout.ImageTextListItemData
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/**
+ * A fake in-memory implementation of repository that produces list of [ImageTextListItemData]
+ */
+class RecentOrdersDataRepository {
+ private val data = MutableStateFlow(listOf())
+ private var items = demoItems.take(MAX_ITEMS)
+
+ /**
+ * Flow of [ImageTextListItemData]s that can be listened to during a Glance session.
+ */
+ fun data(): Flow> = data
+
+ /**
+ * Loads the list of [ImageTextListItemData]s.
+ */
+ fun load(context: Context): List {
+ data.value = if (items.isNotEmpty()) {
+ processImagesAndBuildData(items, context)
+ } else {
+ listOf()
+ }
+
+ return data.value
+ }
+
+ private fun processImagesAndBuildData(items: List, context: Context): List {
+ val mappedItems =
+ items.map { item ->
+ return@map ImageTextListItemData(
+ key = item.key,
+ title = item.title,
+ supportingText = item.supportingText,
+ supportingImage = item.supportingImage,
+ trailingIconButton = R.drawable.add_shopping_cart,
+ trailingIconButtonContentDescription =
+ getString(context, R.string.add_to_cart_content_description),
+ snackKeys = item.snackKeys,
+ )
+ }
+
+ return mappedItems
+ }
+
+ /**
+ * snackKey: This app adds snacks to the cart based on where the [Snack] is positioned in a list
+ */
+ data class DemoDataItem(
+ val key: String,
+ val snackKeys: List,
+ val orderLine: List = snackKeys.map { snacks[it] },
+ val title: String = orderLine[0].name,
+ val supportingText: String = orderLine.joinToString { it.name },
+ @DrawableRes val supportingImage: Int = orderLine[0].imageRes,
+ @DrawableRes val trailingIconButton: Int? = null,
+ val trailingIconButtonContentDescription: String? = null,
+ )
+
+ companion object {
+ private const val MAX_ITEMS = 10
+
+ private val demoItems = listOf(
+ DemoDataItem(
+ key = "1",
+ snackKeys = listOf(0, 20),
+ ),
+ DemoDataItem(
+ key = "2",
+ snackKeys = listOf(1, 21),
+ ),
+ DemoDataItem(
+ key = "3",
+ snackKeys = listOf(2, 22),
+ ),
+ DemoDataItem(
+ key = "4",
+ snackKeys = listOf(3, 23),
+ ),
+ DemoDataItem(
+ key = "5",
+ snackKeys = listOf(4, 24),
+ ),
+ )
+
+ private val repositories = mutableMapOf()
+
+ /**
+ * Returns the repository instance for the given widget represented by [glanceId].
+ */
+ fun getImageTextListDataRepo(glanceId: GlanceId): RecentOrdersDataRepository = synchronized(repositories) {
+ repositories.getOrPut(glanceId) { RecentOrdersDataRepository() }
+ }
+
+ /**
+ * Cleans up local data associated with the provided [glanceId].
+ */
+ fun cleanUp(glanceId: GlanceId) {
+ synchronized(repositories) {
+ repositories.remove(glanceId)
+ }
+ }
+ }
+}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/EmptyListContent.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/EmptyListContent.kt
new file mode 100644
index 0000000000..c3cdb3f622
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/EmptyListContent.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2025 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.jetsnack.widget.layout
+
+import androidx.compose.runtime.Composable
+import androidx.glance.LocalContext
+import com.example.jetsnack.R
+import com.example.jetsnack.widget.utils.ActionUtils
+
+/**
+ * Content to be displayed when there are no items in the list. To be displayed below the
+ * app-specific title bar in the [androidx.glance.appwidget.components.Scaffold] .
+ */
+@Composable
+internal fun EmptyListContent() {
+ val context = LocalContext.current
+
+ NoDataContent(
+ noDataText = context.getString(R.string.sample_no_data_text),
+ noDataIconRes = R.drawable.cupcake,
+ actionButtonText = context.getString(R.string.sample_add_button_text),
+ actionButtonIcon = R.drawable.cupcake,
+ actionButtonOnClick = ActionUtils.actionStartDemoActivity("on-click of add item button"),
+ )
+}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ImageTextListLayout.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ImageTextListLayout.kt
new file mode 100644
index 0000000000..cb42a9f6c9
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ImageTextListLayout.kt
@@ -0,0 +1,524 @@
+/*
+ * Copyright 2025 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.jetsnack.widget.layout
+
+import android.content.Intent
+import androidx.annotation.DrawableRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+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.action.Action
+import androidx.glance.action.ActionParameters
+import androidx.glance.action.actionParametersOf
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.components.CircleIconButton
+import androidx.glance.appwidget.components.Scaffold
+import androidx.glance.appwidget.components.TitleBar
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.background
+import androidx.glance.layout.ContentScale
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.preview.ExperimentalGlancePreviewApi
+import androidx.glance.preview.Preview
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import com.example.jetsnack.R
+import com.example.jetsnack.widget.layout.Dimensions.NUM_GRID_CELLS
+import com.example.jetsnack.widget.layout.Dimensions.fillItemItemPadding
+import com.example.jetsnack.widget.layout.Dimensions.filledItemCornerRadius
+import com.example.jetsnack.widget.layout.Dimensions.imageCornerRadius
+import com.example.jetsnack.widget.layout.Dimensions.verticalSpacing
+import com.example.jetsnack.widget.layout.Dimensions.widgetPadding
+import com.example.jetsnack.widget.layout.ImageTextListLayoutSize.Companion.shouldDisplayTrailingIconButton
+import com.example.jetsnack.widget.layout.ImageTextListLayoutSize.Companion.showTitleBar
+import com.example.jetsnack.widget.layout.ImageTextListLayoutSize.Large
+import com.example.jetsnack.widget.layout.ImageTextListLayoutSize.Medium
+import com.example.jetsnack.widget.layout.ImageTextListLayoutSize.Small
+import com.example.jetsnack.widget.utils.ActionUtils.actionStartDemoActivity
+import com.example.jetsnack.widget.utils.LargeWidgetPreview
+import com.example.jetsnack.widget.utils.MediumWidgetPreview
+import com.example.jetsnack.widget.utils.SmallWidgetPreview
+
+private val CART_ITEMS_KEY = ActionParameters.Key("CART_ITEMS_KEY")
+
+/**
+ * A layout focused on presenting a list of text with an image, and an optional icon button. The
+ * list is displayed in a [Scaffold] below an app-specific title bar.
+ *
+ * The layout drops not-so-important details as the size of the widget goes smaller. For instance,
+ * in the smallest size it drops the image and the icon button to allow displaying more items at
+ * at glance. In an medium size, displays image, text and optionally icon button in a single
+ * column list. In the large size, shows items in 2 column grid.
+ *
+ * In this sample layout, text is the primary focus of the widget and image acts as a supporting
+ * content. So, we prefer displaying horizontal [ListItem]s in most displays.
+ *
+ * The layout serves as an implementation suggestion, but should be customized to fit your
+ * product's needs. As you customize the layout, prefer supporting narrower as well as larger
+ * widget sizes.
+ *
+ * Note: When using images as bitmap, you should limit the number of items displayed in widgets.
+ *
+ * @param title the text to be displayed as title of the widget, e.g. name of your widget or app.
+ * @param titleIconRes a tintable icon that represents your app or brand, that can be displayed
+ * with the provided [title]. In this sample, we use icon from a drawable resource, but you should
+ * use an appropriate icon source for your use case.
+ * @param titleBarActionIconRes resource id of a tintable icon that can be displayed as
+ * an icon button within the title bar area of the widget. For example, a search icon.
+ * @param titleBarActionIconContentDescription description of the [titleBarActionIconRes] button
+ * to be used by the accessibility services.
+ * @param titleBarAction action to be performed on click of the [titleBarActionIconRes] button.
+ * @param items list of items to be displayed in the list; typically includes a short title for
+ * item, a supporting text and an image.
+ *
+ * @see [ImageTextListItemData] for accepted inputs.
+ */
+@Composable
+fun ImageTextListLayout(
+ title: String,
+ @DrawableRes titleIconRes: Int,
+ @DrawableRes titleBarActionIconRes: Int,
+ titleBarActionIconContentDescription: String,
+ titleBarAction: Action,
+ items: List,
+ shoppingCartActionIntent: Intent,
+) {
+ val imageTextListLayoutSize = ImageTextListLayoutSize.fromLocalSize()
+
+ fun titleBar(): @Composable (() -> Unit) = {
+ TitleBar(
+ startIcon = ImageProvider(titleIconRes),
+ title = title.takeIf { imageTextListLayoutSize != Small } ?: "",
+ iconColor = GlanceTheme.colors.primary,
+ textColor = GlanceTheme.colors.onSurface,
+ actions = {
+ CircleIconButton(
+ imageProvider = ImageProvider(titleBarActionIconRes),
+ contentDescription = titleBarActionIconContentDescription,
+ contentColor = GlanceTheme.colors.secondary,
+ backgroundColor = null, // transparent
+ onClick = titleBarAction,
+ )
+ },
+ )
+ }
+
+ val scaffoldTopPadding = if (showTitleBar()) {
+ 0.dp
+ } else {
+ widgetPadding
+ }
+
+ Scaffold(
+ backgroundColor = GlanceTheme.colors.widgetBackground,
+ modifier = GlanceModifier.padding(
+ top = scaffoldTopPadding,
+ bottom = widgetPadding,
+ ),
+ titleBar = if (showTitleBar()) {
+ titleBar()
+ } else {
+ null
+ },
+ ) {
+ Content(items, shoppingCartActionIntent)
+ }
+}
+
+@Composable
+private fun Content(items: List, shoppingCartActionIntent: Intent) {
+ val displayTrailingIconIfPresent = shouldDisplayTrailingIconButton()
+
+ if (items.isEmpty()) {
+ EmptyListContent()
+ } else {
+ when (ImageTextListLayoutSize.fromLocalSize()) {
+ Small -> {
+ ListView(
+ items = items,
+ displayImage = false,
+ displayTrailingIconIfPresent = displayTrailingIconIfPresent,
+ shoppingCartActionIntent = shoppingCartActionIntent,
+ )
+ }
+
+ Medium -> {
+ ListView(
+ items = items,
+ displayImage = true,
+ displayTrailingIconIfPresent = displayTrailingIconIfPresent,
+ shoppingCartActionIntent = shoppingCartActionIntent,
+ )
+ }
+
+ Large -> {
+ GridView(
+ items = items,
+ displayImage = true,
+ displayTrailingIconIfPresent = displayTrailingIconIfPresent,
+ shoppingCartActionIntent = shoppingCartActionIntent,
+ )
+ }
+ }
+ }
+}
+
+/**
+ * A vertical scrolling list displaying [FilledHorizontalListItem]s. Suitable for
+ * [ImageTextListLayoutSize.Small] and [ImageTextListLayoutSize.Large] sizes.
+ */
+@Composable
+private fun ListView(
+ items: List,
+ displayImage: Boolean,
+ displayTrailingIconIfPresent: Boolean,
+ shoppingCartActionIntent: Intent,
+) {
+ RoundedScrollingLazyColumn(
+ modifier = GlanceModifier.fillMaxSize(),
+ items = items,
+ verticalItemsSpacing = verticalSpacing,
+ itemContentProvider = { item ->
+ FilledHorizontalListItem(
+ item = item,
+ displayImage = displayImage,
+ displayTrailingIcon = displayTrailingIconIfPresent,
+ modifier = GlanceModifier.fillMaxSize(),
+ shoppingCartActionIntent = shoppingCartActionIntent,
+ )
+ },
+ )
+}
+
+/**
+ * A grid of [FilledHorizontalListItem]s suitable for [ImageTextListLayoutSize.Large] sizes.
+ *
+ * Supporting the grid display allows large screen users view more information at once.
+ */
+@Composable
+private fun GridView(
+ items: List,
+ displayImage: Boolean,
+ displayTrailingIconIfPresent: Boolean,
+ shoppingCartActionIntent: Intent,
+) {
+ RoundedScrollingLazyVerticalGrid(
+ gridCells = NUM_GRID_CELLS,
+ items = items,
+ cellSpacing = verticalSpacing,
+ itemContentProvider = { item ->
+ FilledHorizontalListItem(
+ item = item,
+ displayImage = displayImage,
+ displayTrailingIcon = displayTrailingIconIfPresent,
+ modifier = GlanceModifier.fillMaxSize(),
+ shoppingCartActionIntent = shoppingCartActionIntent,
+ )
+ },
+ modifier = GlanceModifier.fillMaxSize(),
+ )
+}
+
+/**
+ * Arranges the texts, the image and the icon button in a horizontal arrangement with a filled
+ * container.
+ */
+@Composable
+private fun FilledHorizontalListItem(
+ item: ImageTextListItemData,
+ displayImage: Boolean,
+ displayTrailingIcon: Boolean,
+ modifier: GlanceModifier = GlanceModifier,
+ shoppingCartActionIntent: Intent,
+) {
+ @Composable
+ fun TitleText() {
+ Text(
+ text = item.title,
+ maxLines = 2,
+ style = TextStyles.titleText,
+ )
+ }
+
+ @Composable
+ fun SupportingText() {
+ Text(
+ text = item.supportingText,
+ maxLines = 2,
+ style = TextStyles.supportingText,
+ )
+ }
+
+ @Composable
+ fun SupportingImage() {
+ item.supportingImage?.let {
+ Image(
+ provider = ImageProvider(item.supportingImage.toString().toInt()),
+ // contentDescription is null because in this sample, it serves merely as a visual; but if
+ // it gives additional info to user, you should set the appropriate content description.
+ contentDescription = null,
+ // Depending on your image content, you may want to select an appropriate ContentScale.
+ contentScale = ContentScale.Crop,
+ // Fixed size per UX spec
+ modifier = modifier.cornerRadius(imageCornerRadius).size(Dimensions.imageSize),
+ )
+ }
+ }
+
+ @Composable
+ fun IconButton(intent: Intent = shoppingCartActionIntent.clone() as Intent) {
+ if (item.trailingIconButton != null) {
+ // Using CircleIconButton allows us to keep the touch target 48x48
+ CircleIconButton(
+ imageProvider = ImageProvider(item.trailingIconButton),
+ backgroundColor = null, // to show transparent background.
+ contentDescription = item.trailingIconButtonContentDescription,
+ onClick = actionStartActivity(
+ intent,
+ actionParametersOf(
+ CART_ITEMS_KEY to item.snackKeys.joinToString(separator = " "),
+ ),
+ ),
+ )
+ }
+ }
+
+ ListItem(
+ modifier = modifier
+ .padding(fillItemItemPadding)
+ .cornerRadius(filledItemCornerRadius)
+ .background(GlanceTheme.colors.secondaryContainer),
+ headlineContent = { TitleText() },
+ supportingContent = { SupportingText() },
+ leadingContent = if (displayImage) {
+ { SupportingImage() }
+ } else {
+ null
+ },
+ trailingContent = if (displayTrailingIcon) {
+ { IconButton() }
+ } else {
+ null
+ },
+ )
+}
+
+/**
+ * Holds data fields for a ImageTextListLayout.
+ *
+ * @param key a unique identifier for a specific item
+ * @param title a short text (1-3 words) representing the item
+ * @param supportingText a compact text (~50-55 characters) supporting the [title]; this allows
+ * keeping the title short and glanceable, as well as helps support smaller
+ * widget sizes.
+ * @param supportingImage an image to accompany the textual information displayed in [title]
+ * and the [supportingText].
+ * @param trailingIconButton a tintable icon representing an action that can be performed in context
+ * of the item; e.g. bookmark icon, save icon, etc.
+ * @param trailingIconButtonContentDescription description of the [trailingIconButton] to be used by
+ * the accessibility services.
+ */
+data class ImageTextListItemData(
+ val key: String,
+ val title: String,
+ val supportingText: String,
+ @DrawableRes val supportingImage: Int? = null,
+ @DrawableRes val trailingIconButton: Int? = null,
+ val trailingIconButtonContentDescription: String? = null,
+ val snackKeys: List,
+)
+
+/**
+ * Reference breakpoints for deciding on widget style to display e.g. list / grid etc.
+ *
+ * In this layout, only width breakpoints are used to scale the layout.
+ */
+private enum class ImageTextListLayoutSize(val maxWidth: Dp) {
+ // Single column vertical list without images or trailing button in this size.
+ Small(maxWidth = 260.dp),
+
+ // Single column horizontal list with images and optional trailing button if exists.
+ Medium(maxWidth = 479.dp),
+
+ // 2 Column Grid of horizontal list items. Images are always shown; trailing button is shown if
+ // it fits.
+ Large(maxWidth = 644.dp),
+ ;
+
+ companion object {
+ /**
+ * Returns the corresponding [ImageTextListLayoutSize] to be considered for the current
+ * widget size.
+ */
+ @Composable
+ fun fromLocalSize(): ImageTextListLayoutSize {
+ val width = LocalSize.current.width
+
+ return if (width >= Medium.maxWidth) {
+ Large
+ } else if (width >= Small.maxWidth) {
+ Medium
+ } else {
+ Small
+ }
+ }
+
+ @Composable
+ fun showTitleBar(): Boolean = LocalSize.current.height >= 180.dp
+
+ /**
+ * Returns if icon button should be displayed across medium and large sizes based on
+ * predefined breakpoints.
+ */
+ @Composable
+ fun shouldDisplayTrailingIconButton(): Boolean {
+ val widgetWidth = LocalSize.current.width
+ return (widgetWidth in 340.dp..479.dp || widgetWidth > 620.dp)
+ }
+ }
+}
+
+private object TextStyles {
+ /**
+ * Style for the text displayed as title within each item.
+ */
+ val titleText: TextStyle
+ @Composable get() = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = if (ImageTextListLayoutSize.fromLocalSize() == Small) {
+ 14.sp // M3 Title Small
+ } else {
+ 16.sp // M3 Title Medium
+ },
+ color = GlanceTheme.colors.onSurface,
+ )
+
+ /**
+ * Style for the text displayed as supporting text within each item.
+ */
+ val supportingText: TextStyle
+ @Composable get() =
+ TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp, // M3 Label Medium
+ color = GlanceTheme.colors.secondary,
+ )
+}
+
+private object Dimensions {
+ /** Number of cells in the grid, when items are displayed as a grid. */
+ const val NUM_GRID_CELLS = 2
+
+ /** Padding around the the widget content */
+ val widgetPadding = 12.dp
+
+ /** Corner radius for each filled list item. */
+ val filledItemCornerRadius = 16.dp
+
+ /** Padding applied to each item in the list. */
+ val fillItemItemPadding = 12.dp
+
+ /** Vertical Space between each item in the list. */
+ val verticalSpacing = 4.dp
+
+ /** Size in which images should be displayed in the list. */
+ val imageSize: Dp = 68.dp
+
+ /** Corner radius for image in each item. */
+ val imageCornerRadius = 12.dp
+}
+
+/**
+ * Preview sizes for the widget covering the width based breakpoints of the image grid layout.
+ *
+ * This allows verifying updates across multiple breakpoints.
+ */
+@OptIn(ExperimentalGlancePreviewApi::class)
+@Preview(widthDp = 259, heightDp = 200)
+@Preview(widthDp = 261, heightDp = 200)
+@Preview(widthDp = 480, heightDp = 200)
+@Preview(widthDp = 644, heightDp = 200)
+private annotation class ImageTextListBreakpointPreviews
+
+/**
+ * Previews for the image grid layout with both title and supporting text below the image
+ *
+ * First we look at the previews at defined breakpoints, tweaking them as necessary. In addition,
+ * the previews at standard sizes allows us to quickly verify updates across min / max and common
+ * widget sizes without needing to run the app or manually place the widget.
+ */
+@ImageTextListBreakpointPreviews
+@SmallWidgetPreview
+@MediumWidgetPreview
+@LargeWidgetPreview
+@Composable
+private fun ImageTextListLayoutPreview() {
+ val context = LocalContext.current
+
+ ImageTextListLayout(
+ title = context.getString(R.string.widget_title),
+ titleIconRes = R.drawable.widget_logo,
+ titleBarActionIconRes = R.drawable.add_shopping_cart,
+ titleBarActionIconContentDescription = context.getString(
+ R.string.shopping_cart_button_label,
+ ),
+ titleBarAction = actionStartDemoActivity("Title bar action click"),
+ items = listOf(
+ ImageTextListItemData(
+ key = "1",
+ snackKeys = listOf(0, 20),
+ supportingText = "Some text",
+ title = "Some title",
+ ),
+ ImageTextListItemData(
+ key = "1",
+ snackKeys = listOf(1, 21),
+ supportingText = "Some text",
+ title = "Some title",
+ ),
+ ImageTextListItemData(
+ key = "1",
+ snackKeys = listOf(2, 22),
+ supportingText = "Some text",
+ title = "Some title",
+ ),
+ ImageTextListItemData(
+ key = "1",
+ snackKeys = listOf(3, 23),
+ supportingText = "Some text",
+ title = "Some title",
+ ),
+ ImageTextListItemData(
+ key = "1",
+ snackKeys = listOf(4, 24),
+ supportingText = "Some text",
+ title = "Some title",
+ ),
+ ),
+ shoppingCartActionIntent = Intent(),
+ )
+}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ListItem.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ListItem.kt
new file mode 100644
index 0000000000..b66086cf21
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ListItem.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2025 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.jetsnack.widget.layout
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.glance.GlanceModifier
+import androidx.glance.action.Action
+import androidx.glance.action.clickable
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.width
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
+
+/**
+ * Component to build an item within a list. Lists are a continuous, vertical indexes of text or
+ * images.
+ *
+ * Apply padding to the item using the [androidx.glance.layout.padding] modifier.
+ *
+ * @param headlineContent the [Composable] headline content of the list item; typically a 1-line
+ * prominent text within the list item
+ * @param modifier [GlanceModifier] to be applied to the list item
+ * @param contentSpacing spacing between the leading, center and trailing sections; default 16.dp
+ * @param supportingContent 1-2 line text to be displayed below the headline text of the list item
+ * @param leadingContent the leading content of the list item such as an image, icon, or a selection
+ * control such as checkbox, switch, or a radio button
+ * @param trailingContent the trailing meta text, icon, or a selection control such as switch,
+ * checkbox, or a radio button
+ * @param onClick an option action to be performed on click of the list item.
+ * @param itemContentDescription an optional text used by accessibility services to describe what
+ * this list item represents. If not provided, the non-clickable
+ * content within the list item will be read out.
+ */
+@Composable
+fun ListItem(
+ headlineContent: @Composable (() -> Unit),
+ modifier: GlanceModifier = GlanceModifier,
+ contentSpacing: Dp = 16.dp,
+ supportingContent: @Composable (() -> Unit)? = null,
+ leadingContent: @Composable (() -> Unit)? = null,
+ trailingContent: @Composable (() -> Unit)? = null,
+ onClick: Action? = null,
+ itemContentDescription: String? = null,
+) {
+ val listItemModifier = if (itemContentDescription != null) {
+ modifier.semantics { contentDescription = itemContentDescription }
+ } else {
+ modifier
+ }
+
+ Row(
+ modifier = listItemModifier.maybeClickable(onClick),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Leading
+ leadingContent?.let {
+ it()
+ Spacer(modifier = GlanceModifier.width(contentSpacing))
+ }
+ // Center
+ Column(
+ modifier = GlanceModifier.defaultWeight(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ headlineContent()
+ supportingContent?.let { it() }
+ }
+ // Trailing
+ trailingContent?.let {
+ Spacer(modifier = GlanceModifier.width(contentSpacing))
+ it()
+ }
+ }
+}
+
+private fun GlanceModifier.maybeClickable(action: Action?): GlanceModifier = if (action != null) {
+ this.clickable(action)
+} else {
+ this
+}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/NoDataContent.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/NoDataContent.kt
new file mode 100644
index 0000000000..2bbe4a4f9e
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/NoDataContent.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2025 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.jetsnack.widget.layout
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.ColorFilter
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.LocalSize
+import androidx.glance.action.Action
+import androidx.glance.appwidget.components.FilledButton
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.height
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+
+/**
+ * Component for a view that can be shown when the app has no data to present.
+ *
+ * The content should be displayed in a [androidx.glance.appwidget.components.Scaffold] below an app-specific
+ * title bar.
+ *
+ * @param noDataText text indicating that there is no data available to display.
+ * @param noDataIconRes a tintable icon indicating there is no data available to display; usually
+ * a crossed-out icon representing the data that is empty; e.g. a crossed out
+ * message icon if there were no messages.
+ * @param actionButtonText text for the button that performs specific operation when there is no
+ * data; e.g. sign-in button, add button, etc.
+ * @param actionButtonIcon a leading icon to be displayed within the action button.
+ * @param actionButtonOnClick action to be performed on click of the action button.
+ */
+@Composable
+fun NoDataContent(noDataIconRes: Int, noDataText: String, actionButtonText: String, actionButtonIcon: Int, actionButtonOnClick: Action) {
+ @Composable
+ fun showIcon() = LocalSize.current.height >= 180.dp
+
+ Column(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = GlanceModifier.fillMaxSize(),
+ ) {
+ if (showIcon()) {
+ Image(
+ provider = ImageProvider(noDataIconRes),
+ colorFilter = ColorFilter.tint(GlanceTheme.colors.secondary),
+ contentDescription = null, // only decorative
+ )
+ Spacer(modifier = GlanceModifier.height(8.dp))
+ }
+ Text(
+ text = noDataText,
+ style = TextStyle(
+ fontWeight = FontWeight.Medium,
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 16.sp, // M3 - title/medium
+ ),
+ )
+ Spacer(modifier = GlanceModifier.height(8.dp))
+ FilledButton(
+ text = actionButtonText,
+ icon = ImageProvider(actionButtonIcon),
+ onClick = actionButtonOnClick,
+ )
+ }
+}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyColumn.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyColumn.kt
new file mode 100644
index 0000000000..557abb417e
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyColumn.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2025 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.jetsnack.widget.layout
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.glance.GlanceModifier
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.lazy.LazyColumn
+import androidx.glance.appwidget.lazy.LazyListScope
+import androidx.glance.appwidget.lazy.items
+import androidx.glance.appwidget.lazy.itemsIndexed
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+
+/**
+ * A variant of [LazyColumn] that clips its scrolling content to a rounded rectangle.
+ *
+ * @param modifier the modifier to apply to this layout
+ * @param horizontalAlignment the horizontal alignment applied to the items.
+ * @param content a block which describes the content. Inside this block you can use methods like
+ * [LazyListScope.item] to add a single item or [LazyListScope.items] to add a list of items. If the
+ * item has more than one top-level child, they will be automatically wrapped in a Box.
+ * @see LazyColumn
+ */
+@Composable
+fun RoundedScrollingLazyColumn(
+ modifier: GlanceModifier = GlanceModifier,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ content: LazyListScope.() -> Unit,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .cornerRadius(16.dp), // to present a rounded scrolling experience
+ ) {
+ LazyColumn(
+ horizontalAlignment = horizontalAlignment,
+ content = content,
+ )
+ }
+}
+
+/**
+ * * A variant of [LazyColumn] that clips its scrolling content to a rounded rectangle and spaces
+ * out each item in the list with a default 8.dp spacing
+ *
+ * @param items the list of data items to be displayed in the list
+ * @param itemContentProvider a lambda function that provides item content without any spacing
+ * @param modifier the modifier to apply to this layout
+ * @param horizontalAlignment the horizontal alignment applied to the items.
+ * @param verticalItemsSpacing vertical spacing between items
+ * @see LazyColumn
+ */
+@Composable
+fun RoundedScrollingLazyColumn(
+ items: List,
+ itemContentProvider: @Composable (item: T) -> Unit,
+ modifier: GlanceModifier = GlanceModifier,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ verticalItemsSpacing: Dp = 4.dp,
+) {
+ val lastIndex = items.size - 1
+
+ RoundedScrollingLazyColumn(modifier, horizontalAlignment) {
+ itemsIndexed(items) { index, item ->
+ Column(modifier = GlanceModifier.fillMaxWidth()) {
+ itemContentProvider(item)
+ if (index != lastIndex) {
+ Spacer(modifier = GlanceModifier.height(verticalItemsSpacing))
+ }
+ }
+ }
+ }
+}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyVerticalGrid.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyVerticalGrid.kt
new file mode 100644
index 0000000000..d6092961dc
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyVerticalGrid.kt
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2025 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.jetsnack.widget.layout
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.glance.GlanceModifier
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.lazy.GridCells
+import androidx.glance.appwidget.lazy.LazyVerticalGrid
+import androidx.glance.appwidget.lazy.LazyVerticalGridScope
+import androidx.glance.appwidget.lazy.items
+import androidx.glance.appwidget.lazy.itemsIndexed
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.padding
+import kotlin.math.ceil
+
+/**
+ * A variant of [LazyVerticalGrid] that clips its scrolling content to a rounded rectangle.
+ *
+ * @param gridCells the number of columns in the grid.
+ * @param modifier the modifier to apply to this layout
+ * @param horizontalAlignment the horizontal alignment applied to the items.
+ * @param content a block which describes the content. Inside this block you can use methods like
+ * [LazyVerticalGridScope.item] to add a single item or [LazyVerticalGridScope.items] to add a list
+ * of items. If the item has more than one top-level child, they will be automatically wrapped in a
+ * Box.
+ * @see LazyVerticalGrid
+ */
+@Composable
+fun RoundedScrollingLazyVerticalGrid(
+ gridCells: GridCells,
+ modifier: GlanceModifier = GlanceModifier,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ content: LazyVerticalGridScope.() -> Unit,
+) {
+ Box(
+ modifier = GlanceModifier
+ .cornerRadius(16.dp) // to present a rounded scrolling experience
+ .then(modifier),
+ ) {
+ LazyVerticalGrid(
+ gridCells = gridCells,
+ horizontalAlignment = horizontalAlignment,
+ content = content,
+ )
+ }
+}
+
+/**
+ * A variant of [LazyVerticalGrid] that clips its scrolling content to a rounded rectangle and
+ * spaces out each item in the grid with a default 8.dp spacing.
+ *
+ * @param gridCells number of columns in the grid
+ * @param items the list of data items to be displayed in the list
+ * @param itemContentProvider a lambda function that provides item content without any spacing
+ * @param modifier the modifier to apply to this layout
+ * @param horizontalAlignment the horizontal alignment applied to the items.
+ * @param cellSpacing horizontal and vertical spacing between cells
+ * @see LazyVerticalGrid
+ */
+@Composable
+fun RoundedScrollingLazyVerticalGrid(
+ gridCells: Int,
+ items: List,
+ itemContentProvider: @Composable (item: T) -> Unit,
+ modifier: GlanceModifier = GlanceModifier,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ cellSpacing: Dp = 12.dp,
+) {
+ val numRows = ceil(items.size.toDouble() / gridCells).toInt()
+
+ // Cell spacing is achieved by allocating equal amount of padding to each cell. Cells on edge
+ // apply it completely to inner sides, while cells not on edge apply it evenly on sides.
+ val perCellHorizontalPadding = (cellSpacing * (gridCells - 1)) / gridCells
+ val perCellVerticalPadding = (cellSpacing * (numRows - 1)) / numRows
+
+ RoundedScrollingLazyVerticalGrid(
+ gridCells = GridCells.Fixed(gridCells),
+ horizontalAlignment = horizontalAlignment,
+ modifier = modifier,
+ ) {
+ itemsIndexed(items) { index, item ->
+ val row = index / gridCells
+ val column = index % gridCells
+
+ val cellTopPadding = when (row) {
+ 0 -> 0.dp
+ numRows - 1 -> perCellVerticalPadding
+ else -> perCellVerticalPadding / 2
+ }
+
+ val cellBottomPadding = when (row) {
+ 0 -> perCellVerticalPadding
+ numRows - 1 -> 0.dp
+ else -> perCellVerticalPadding / 2
+ }
+
+ val cellStartPadding = when (column) {
+ 0 -> 0.dp
+ gridCells - 1 -> perCellHorizontalPadding
+ else -> perCellHorizontalPadding / 2
+ }
+
+ val cellEndPadding = when (column) {
+ 0 -> perCellHorizontalPadding
+ gridCells - 1 -> 0.dp
+ else -> perCellHorizontalPadding / 2
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(
+ start = cellStartPadding,
+ end = cellEndPadding,
+ top = cellTopPadding,
+ bottom = cellBottomPadding,
+ ),
+ ) {
+ itemContentProvider(item)
+ }
+ }
+ }
+}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/ActionUtils.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/ActionUtils.kt
new file mode 100644
index 0000000000..a4f0daea45
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/ActionUtils.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2025 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.jetsnack.widget.utils
+
+import androidx.compose.runtime.Composable
+import androidx.glance.action.Action
+import androidx.glance.action.actionParametersOf
+import androidx.glance.action.actionStartActivity
+import com.example.jetsnack.widget.ActionDemonstrationActivity
+import com.example.jetsnack.widget.ActionSourceMessageKey
+
+/**
+ * Utility functions for creating [Action]s.
+ */
+object ActionUtils {
+ /**
+ * [Action] for launching the [ActionDemonstrationActivity] with the given message.
+ */
+ @Composable
+ fun actionStartDemoActivity(message: String) = actionStartActivity(
+ actionParametersOf(
+ ActionSourceMessageKey to message,
+ ),
+ )
+}
diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/PreviewAnnotations.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/PreviewAnnotations.kt
new file mode 100644
index 0000000000..3ad68ab8c6
--- /dev/null
+++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/PreviewAnnotations.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2023-2025 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.jetsnack.widget.utils
+
+import androidx.glance.preview.ExperimentalGlancePreviewApi
+import androidx.glance.preview.Preview
+
+/**
+ * Previews for 2x2 sized widgets in handheld and tablets.
+ *
+ * https://developer.android.com/design/ui/mobile/guides/widgets/sizing
+ */
+@OptIn(ExperimentalGlancePreviewApi::class)
+// Reference devices
+@Preview(widthDp = 172, heightDp = 234) // pixel 6a - smaller phone (2x2 in 4x5 grid - portrait)
+@Preview(widthDp = 172, heightDp = 224) // pixel 7 pro - larger phone (2x2 in 4x5 grid - portrait)
+@Preview(widthDp = 304, heightDp = 229) // Pixel tablet (2x2 in 6x5 grid - landscape)
+// Min / max sizes
+@Preview(widthDp = 109, heightDp = 115) // min handheld
+@Preview(widthDp = 306, heightDp = 276) // max handheld
+@Preview(widthDp = 180, heightDp = 184) // min tablet
+@Preview(widthDp = 304, heightDp = 304) // max tablet
+annotation class SmallWidgetPreview
+
+/**
+ * Previews for 4x2 handheld and 3x2 tablet widgets.
+ *
+ * https://developer.android.com/design/ui/mobile/guides/widgets/sizing
+ */
+@OptIn(ExperimentalGlancePreviewApi::class)
+// Reference devices
+@Preview(widthDp = 360, heightDp = 234) // pixel 6a - smaller phone (4x2 in 4x5 grid - portrait)
+@Preview(widthDp = 360, heightDp = 224) // pixel 7 pro - larger phone (4x2 in 4x5 grid - portrait)
+@Preview(widthDp = 488, heightDp = 229) // Pixel tablet (3x2 in 6x5 grid - landscape)
+// Min / max sizes
+@Preview(widthDp = 245, heightDp = 115) // min handheld
+@Preview(widthDp = 624, heightDp = 276) // max handheld
+@Preview(widthDp = 298, heightDp = 184) // min tablet
+@Preview(widthDp = 488, heightDp = 304) // max tablet
+annotation class MediumWidgetPreview
+
+/**
+ * Previews for 4x3 handheld and 3x3 tablet widgets.
+ *
+ * https://developer.android.com/design/ui/mobile/guides/widgets/sizing
+ */
+@OptIn(ExperimentalGlancePreviewApi::class)
+// Reference devices
+@Preview(widthDp = 360, heightDp = 359) // pixel 6a - smaller phone (4x3 in 4x5 grid - portrait)
+@Preview(widthDp = 360, heightDp = 344) // pixel 7 pro - larger phone (4x3 in 4x5 grid - portrait)
+@Preview(widthDp = 488, heightDp = 352) // Pixel tablet (3x3 in 6x5 grid - landscape)
+// Min / max sizes
+@Preview(widthDp = 245, heightDp = 185) // min handheld
+@Preview(widthDp = 624, heightDp = 422) // max handheld
+@Preview(widthDp = 298, heightDp = 424) // min tablet
+@Preview(widthDp = 488, heightDp = 672) // max tablet
+annotation class LargeWidgetPreview
diff --git a/Jetsnack/app/src/main/res/drawable/add_shopping_cart.xml b/Jetsnack/app/src/main/res/drawable/add_shopping_cart.xml
new file mode 100644
index 0000000000..8dd5277ec9
--- /dev/null
+++ b/Jetsnack/app/src/main/res/drawable/add_shopping_cart.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/Jetsnack/app/src/main/res/drawable/backward_compatible_widget_preview.png b/Jetsnack/app/src/main/res/drawable/backward_compatible_widget_preview.png
new file mode 100644
index 0000000000..06d1f88a06
Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable/backward_compatible_widget_preview.png differ
diff --git a/Jetsnack/app/src/main/res/drawable/shopping_cart.xml b/Jetsnack/app/src/main/res/drawable/shopping_cart.xml
new file mode 100644
index 0000000000..cbbd7df005
--- /dev/null
+++ b/Jetsnack/app/src/main/res/drawable/shopping_cart.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/Jetsnack/app/src/main/res/drawable/widget_logo.xml b/Jetsnack/app/src/main/res/drawable/widget_logo.xml
new file mode 100644
index 0000000000..36009cadda
--- /dev/null
+++ b/Jetsnack/app/src/main/res/drawable/widget_logo.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/Jetsnack/app/src/main/res/values-xlarge/dimens.xml b/Jetsnack/app/src/main/res/values-xlarge/dimens.xml
new file mode 100644
index 0000000000..dd192c4917
--- /dev/null
+++ b/Jetsnack/app/src/main/res/values-xlarge/dimens.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+ 2
+ 2
+ 180dp
+ 184dp
+ 180dp
+ 184dp
+ 488dp
+ 488dp
+
\ No newline at end of file
diff --git a/Jetsnack/app/src/main/res/values/dimens.xml b/Jetsnack/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..5f8b128691
--- /dev/null
+++ b/Jetsnack/app/src/main/res/values/dimens.xml
@@ -0,0 +1,43 @@
+
+
+
+ 0dp
+ 48dp
+
+
+
+
+
+
+
+ 4
+ 2
+ 256dp
+ 115dp
+ 120dp
+ 115dp
+ 624dp
+ 422dp
+
\ No newline at end of file
diff --git a/Jetsnack/app/src/main/res/values/strings.xml b/Jetsnack/app/src/main/res/values/strings.xml
index 120984f545..9c9299ff0f 100644
--- a/Jetsnack/app/src/main/res/values/strings.xml
+++ b/Jetsnack/app/src/main/res/values/strings.xml
@@ -76,4 +76,13 @@
Alphabetical
Close
+
+ Recent Jetsnack orders
+ Jetsnack Recent Orders
+ Quickly view and reorder your recent orders.
+ View shopping cart
+ No data
+ Add
+ Add to Shopping Cart
+
diff --git a/Jetsnack/app/src/main/res/xml/snack_order_widget_info.xml b/Jetsnack/app/src/main/res/xml/snack_order_widget_info.xml
new file mode 100644
index 0000000000..68529b7404
--- /dev/null
+++ b/Jetsnack/app/src/main/res/xml/snack_order_widget_info.xml
@@ -0,0 +1,28 @@
+
+
+
\ No newline at end of file
diff --git a/Jetsnack/gradle/libs.versions.toml b/Jetsnack/gradle/libs.versions.toml
index d40115dfaf..13625c5363 100644
--- a/Jetsnack/gradle/libs.versions.toml
+++ b/Jetsnack/gradle/libs.versions.toml
@@ -12,7 +12,7 @@ androidx-compose-bom = "2025.05.00"
androidx-constraintlayout = "1.1.1"
androidx-core-splashscreen = "1.0.1"
androidx-corektx = "1.16.0"
-androidx-glance = "1.1.1"
+androidx-glance = "1.2.0-alpha01"
androidx-lifecycle = "2.8.2"
androidx-lifecycle-compose = "2.9.0"
androidx-lifecycle-runtime-compose = "2.9.0"
@@ -57,6 +57,7 @@ spotless = "7.0.3"
# @keep
targetSdk = "33"
version-catalog-update = "1.0.0"
+glancePreview = "1.1.1"
[libraries]
accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" }
@@ -158,6 +159,7 @@ roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose"
roborazzi-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }
rometools-modules = { module = "com.rometools:rome-modules", version.ref = "rome" }
rometools-rome = { module = "com.rometools:rome", version.ref = "rome" }
+androidx-glance-preview = { group = "androidx.glance", name = "glance-preview", version.ref = "glancePreview" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
diff --git a/scripts/libs.versions.toml b/scripts/libs.versions.toml
index d40115dfaf..13625c5363 100644
--- a/scripts/libs.versions.toml
+++ b/scripts/libs.versions.toml
@@ -12,7 +12,7 @@ androidx-compose-bom = "2025.05.00"
androidx-constraintlayout = "1.1.1"
androidx-core-splashscreen = "1.0.1"
androidx-corektx = "1.16.0"
-androidx-glance = "1.1.1"
+androidx-glance = "1.2.0-alpha01"
androidx-lifecycle = "2.8.2"
androidx-lifecycle-compose = "2.9.0"
androidx-lifecycle-runtime-compose = "2.9.0"
@@ -57,6 +57,7 @@ spotless = "7.0.3"
# @keep
targetSdk = "33"
version-catalog-update = "1.0.0"
+glancePreview = "1.1.1"
[libraries]
accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" }
@@ -158,6 +159,7 @@ roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose"
roborazzi-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }
rometools-modules = { module = "com.rometools:rome-modules", version.ref = "rome" }
rometools-rome = { module = "com.rometools:rome", version.ref = "rome" }
+androidx-glance-preview = { group = "androidx.glance", name = "glance-preview", version.ref = "glancePreview" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }