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" }