Skip to content

Commit 1b6e046

Browse files
authored
Add Recent Orders Widget (#1567)
Uses list widget canonical layout from Platform Samples for resizing. Platform Samples Canonical Widget Layouts: https://github.com/android/platform-samples/tree/main/samples/user-interface/appwidgets Creates a generated widget preview, using APIs in Glance 1.2: https://developer.android.com/jetpack/androidx/releases/glance#1.2.0-alpha01
2 parents 5881fe1 + c94c786 commit 1b6e046

25 files changed

+1635
-8
lines changed

Jetsnack/app/build.gradle.kts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,22 @@ plugins {
2323
}
2424

2525
android {
26-
compileSdk = libs.versions.compileSdk.get().toInt()
26+
compileSdk =
27+
libs.versions.compileSdk
28+
.get()
29+
.toInt()
2730
namespace = "com.example.jetsnack"
2831

2932
defaultConfig {
3033
applicationId = "com.example.jetsnack"
31-
minSdk = libs.versions.minSdk.get().toInt()
32-
targetSdk = libs.versions.targetSdk.get().toInt()
34+
minSdk =
35+
libs.versions.minSdk
36+
.get()
37+
.toInt()
38+
targetSdk =
39+
libs.versions.targetSdk
40+
.get()
41+
.toInt()
3342
versionCode = 1
3443
versionName = "1.0"
3544
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -131,4 +140,7 @@ dependencies {
131140
androidTestImplementation(libs.kotlinx.coroutines.test)
132141
androidTestImplementation(libs.androidx.compose.ui.test)
133142
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
143+
144+
implementation(libs.androidx.glance.appwidget)
145+
implementation(libs.androidx.glance.preview)
134146
}

Jetsnack/app/src/main/AndroidManifest.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@
2525
android:theme="@style/Theme.Jetsnack"
2626

2727
>
28+
<receiver
29+
android:name=".widget.RecentOrdersWidgetReceiver"
30+
android:label="@string/snack_order_widget_name"
31+
android:exported="false">
32+
<intent-filter>
33+
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
34+
</intent-filter>
35+
<meta-data
36+
android:name="android.appwidget.provider"
37+
android:resource="@xml/snack_order_widget_info" />
38+
</receiver>
2839

2940
<profileable
3041
android:shell="true"

Jetsnack/app/src/main/java/com/example/jetsnack/ui/MainActivity.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020 The Android Open Source Project
2+
* Copyright 2020-2025 The Android Open Source Project
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,15 +16,42 @@
1616

1717
package com.example.jetsnack.ui
1818

19+
import android.appwidget.AppWidgetManager
20+
import android.os.Build
1921
import android.os.Bundle
2022
import androidx.activity.ComponentActivity
2123
import androidx.activity.compose.setContent
2224
import androidx.activity.enableEdgeToEdge
25+
import androidx.annotation.RequiresApi
26+
import androidx.glance.appwidget.GlanceAppWidgetManager
27+
import androidx.lifecycle.lifecycleScope
28+
import com.example.jetsnack.widget.RecentOrdersWidgetReceiver
29+
import kotlinx.coroutines.Dispatchers
30+
import kotlinx.coroutines.launch
2331

2432
class MainActivity : ComponentActivity() {
2533
override fun onCreate(savedInstanceState: Bundle?) {
2634
enableEdgeToEdge()
2735
super.onCreate(savedInstanceState)
36+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
37+
lifecycleScope.launch(Dispatchers.Default) {
38+
setWidgetPreviews()
39+
}
40+
}
2841
setContent { JetsnackApp() }
2942
}
43+
44+
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
45+
suspend fun setWidgetPreviews() {
46+
val receiver = RecentOrdersWidgetReceiver::class
47+
val installedProviders = getSystemService(AppWidgetManager::class.java).installedProviders
48+
val providerInfo = installedProviders.firstOrNull {
49+
it.provider.className ==
50+
receiver.qualifiedName
51+
}
52+
providerInfo?.generatedPreviewCategories.takeIf { it == 0 }?.let {
53+
// Set previews if this provider if unset
54+
GlanceAppWidgetManager(this).setWidgetPreviews(receiver)
55+
}
56+
}
3057
}

Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020 The Android Open Source Project
2+
* Copyright 2020-2025 The Android Open Source Project
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -76,6 +76,7 @@ import androidx.navigation.NavBackStackEntry
7676
import androidx.navigation.NavDeepLink
7777
import androidx.navigation.NavGraphBuilder
7878
import androidx.navigation.compose.composable
79+
import androidx.navigation.navDeepLink
7980
import com.example.jetsnack.R
8081
import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope
8182
import com.example.jetsnack.ui.components.JetsnackSurface
@@ -140,7 +141,12 @@ fun NavGraphBuilder.addHomeGraph(onSnackSelected: (Long, String, NavBackStackEnt
140141
modifier,
141142
)
142143
}
143-
composable(HomeSections.CART.route) { from ->
144+
composable(
145+
HomeSections.CART.route,
146+
deepLinks = listOf(
147+
navDeepLink { uriPattern = "https://jetsnack.example.com/home/cart" },
148+
),
149+
) { from ->
144150
Cart(
145151
onSnackClick = { id, origin -> onSnackSelected(id, origin, from) },
146152
modifier,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2023-2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.jetsnack.widget
18+
19+
import androidx.activity.ComponentActivity
20+
import androidx.activity.compose.setContent
21+
import androidx.compose.foundation.layout.Box
22+
import androidx.compose.foundation.layout.fillMaxSize
23+
import androidx.compose.material3.Text
24+
import androidx.compose.ui.Alignment
25+
import androidx.compose.ui.Modifier
26+
import androidx.glance.action.ActionParameters
27+
28+
internal val ActionSourceMessageKey = ActionParameters.Key<String>("actionSourceMessageKey")
29+
30+
/**
31+
* Activity that is launched on clicks from different parts of sample widgets. Displays string
32+
* describing source of the click.
33+
*/
34+
class ActionDemonstrationActivity : ComponentActivity() {
35+
36+
override fun onResume() {
37+
super.onResume()
38+
setContent {
39+
Box(
40+
modifier = Modifier.fillMaxSize(),
41+
contentAlignment = Alignment.Center,
42+
) {
43+
val source = intent.getStringExtra(ActionSourceMessageKey.name) ?: "Unknown"
44+
Text("Launched from $source")
45+
}
46+
}
47+
}
48+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.jetsnack.widget
18+
19+
import android.annotation.SuppressLint
20+
import android.content.Context
21+
import android.content.Intent
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.collectAsState
24+
import androidx.compose.runtime.getValue
25+
import androidx.compose.runtime.key
26+
import androidx.compose.ui.unit.DpSize
27+
import androidx.compose.ui.unit.dp
28+
import androidx.core.net.toUri
29+
import androidx.glance.GlanceId
30+
import androidx.glance.GlanceTheme
31+
import androidx.glance.LocalContext
32+
import androidx.glance.LocalSize
33+
import androidx.glance.appwidget.AppWidgetId
34+
import androidx.glance.appwidget.GlanceAppWidget
35+
import androidx.glance.appwidget.GlanceAppWidgetReceiver
36+
import androidx.glance.appwidget.SizeMode
37+
import androidx.glance.appwidget.action.actionStartActivity
38+
import androidx.glance.appwidget.provideContent
39+
import com.example.jetsnack.R
40+
import com.example.jetsnack.ui.MainActivity
41+
import com.example.jetsnack.widget.data.RecentOrdersDataRepository
42+
import com.example.jetsnack.widget.data.RecentOrdersDataRepository.Companion.getImageTextListDataRepo
43+
import com.example.jetsnack.widget.layout.ImageTextListItemData
44+
import com.example.jetsnack.widget.layout.ImageTextListLayout
45+
import kotlinx.coroutines.Dispatchers
46+
import kotlinx.coroutines.withContext
47+
48+
class RecentOrdersWidget : GlanceAppWidget() {
49+
// Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in
50+
// different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each
51+
// supported size to be held in the widget host's memory.
52+
override val sizeMode: SizeMode = SizeMode.Exact
53+
54+
override val previewSizeMode = SizeMode.Responsive(
55+
setOf(
56+
DpSize(256.dp, 115.dp), // 4x2 cell min size
57+
DpSize(260.dp, 180.dp), // Medium width layout, height with header
58+
),
59+
)
60+
61+
override suspend fun provideGlance(context: Context, id: GlanceId) {
62+
val repo = getImageTextListDataRepo(id)
63+
64+
val initialItems = withContext(Dispatchers.Default) {
65+
repo.load(context)
66+
}
67+
68+
provideContent {
69+
GlanceTheme {
70+
val items by repo.data().collectAsState(initial = initialItems)
71+
72+
key(LocalSize.current) {
73+
WidgetContent(
74+
items = items,
75+
shoppingCartActionIntent = Intent(
76+
context.applicationContext,
77+
MainActivity::class.java,
78+
)
79+
.setAction(Intent.ACTION_VIEW)
80+
.setFlags(
81+
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK,
82+
)
83+
.setData("https://jetsnack.example.com/home/cart".toUri()),
84+
)
85+
}
86+
}
87+
}
88+
}
89+
90+
@Composable
91+
fun WidgetContent(items: List<ImageTextListItemData>, shoppingCartActionIntent: Intent) {
92+
val context = LocalContext.current
93+
94+
ImageTextListLayout(
95+
items = items,
96+
title = context.getString(R.string.widget_title),
97+
titleIconRes = R.drawable.widget_logo,
98+
titleBarActionIconRes = R.drawable.shopping_cart,
99+
titleBarActionIconContentDescription = context.getString(
100+
R.string.shopping_cart_button_label,
101+
),
102+
titleBarAction = actionStartActivity(shoppingCartActionIntent),
103+
shoppingCartActionIntent = shoppingCartActionIntent,
104+
)
105+
}
106+
107+
override suspend fun providePreview(context: Context, widgetCategory: Int) {
108+
val repo = RecentOrdersDataRepository()
109+
val items = repo.load(context)
110+
111+
provideContent {
112+
GlanceTheme {
113+
WidgetContent(
114+
items = items,
115+
shoppingCartActionIntent = Intent(),
116+
)
117+
}
118+
}
119+
}
120+
}
121+
122+
class RecentOrdersWidgetReceiver : GlanceAppWidgetReceiver() {
123+
override val glanceAppWidget = RecentOrdersWidget()
124+
125+
@SuppressLint("RestrictedApi")
126+
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
127+
appWidgetIds.forEach {
128+
RecentOrdersDataRepository.cleanUp(AppWidgetId(it))
129+
}
130+
super.onDeleted(context, appWidgetIds)
131+
}
132+
}

0 commit comments

Comments
 (0)