Skip to content

Commit c3e4062

Browse files
authored
Add local and shared state to Remote Compose Android Pet widget (#508)
* Add local and shared state to Remote Compose Android Pet widget - Implement 3-tap local document state for coin button visibility - Add ClaimCreditButton and StoreButton at BottomEnd matching GamingWidget layout - Add BuddyPreferences and StoreActivity to persist and display per-widget coin counts - Import button vector drawables and add Robolectric/CoreDocument unit tests * Make StoreActivity accessible only from within Android Pet widget * Remove unit tests and test-only bridge from Android Pet widget * Remove redundant onReceive and manual updateAppWidget from AndroidPetWidget * Simplify widget registration and clarify state comments in AndroidPetWidget * Remove unnecessary getIdForCreationState RemoteCanvas block from AndroidPetWidget * Simplify BuddyPreferences by removing BuddyRepository and redundant tap keys * Remove redundant onUpdate override from AndroidPetWidget * Simplify ClaimCreditButton, StoreButton, and WidgetActionBar * Simplify StoreActivity state loading and card UI * Match StoreActivity visual design to PetBuddyStore * Move pet onTouchDown directly onto RemoteColumn and remove inner RemoteBox * Use RemoteImage instead of RemoteBox and RemoteCanvas in ClaimCreditButton and StoreButton * Simplify StoreActivity with dynamic colors, trimmed typography, and reusable pill/chip composables * Clean up StoreActivity by removing StoreEmptyStateCard, unused modifiers, and redundant loads * Flatten StoreActivity Compose UI hierarchy and use M3 FilterChip * Use LazyRow in BuddySelectorBar and simplify StoreTheme and modifiers * Use Scaffold topBar slot and Material 3 TopAppBar in StoreActivity * Remove WidgetAssetProvider and decode drawables directly with toBitmap() * refactoring * Address PR review feedback in README, StoreButton, and ClaimCreditButton
1 parent e9b47e9 commit c3e4062

13 files changed

Lines changed: 783 additions & 16 deletions

File tree

‎samples/user-interface/appwidgets/README.md‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
11
# AppWidgets samples
22

3-
This module demonstrates how to build Android AppWidgets with [Jetpack Glance](https://developer.android.com/jetpack/androidx/releases/glance).
3+
This module demonstrates how to build Android AppWidgets with [Jetpack Glance](https://developer.android.com/jetpack/androidx/releases/glance) and [Remote Compose](https://developer.android.com/jetpack/androidx/releases/compose-remote).
44

55
The new set of APIs allows an application to build more engaging and beautiful widgets. Use this
66
sample together with the
77
[App widget guides](https://developer.android.com/guide/topics/appwidgets/overview).
88

99
# Pre-requisites
1010

11-
* Android 12 (API level 31)
11+
* Android 12 (API level 31) for Jetpack Glance samples; Android 17 (API level 37) for Remote Compose samples
1212
* Android Studio Chipmunk or newer
1313

1414
# Getting Started
1515

16+
## Remote Compose Widget Sample (`remote_compose`)
17+
18+
The [`remote_compose`](./src/main/java/com/example/platform/ui/appwidgets/remote_compose) directory contains the **Android Pet Widget** ([`AndroidPetWidget.kt`](./src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt)) and [`StoreActivity.kt`](./src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt). This sample is a work in progress. We will add more features in the coming months.
19+
20+
### When to use Jetpack Glance vs. Remote Compose
21+
22+
For most cases, use [Jetpack Glance](https://developer.android.com/develop/ui/compose/glance) to build Android widgets. Jetpack Glance is a backwards-compatible library with developer tools (such as Canonical Layouts) that help you build high-quality widgets across form factors.
23+
24+
However, you cannot build some widget use cases with Jetpack Glance, such as widgets that require animations. Jetpack Glance uses the legacy `RemoteViews` system:
25+
* `RemoteViews` cannot evaluate mathematical expressions on the host (for example, to continuously rotate an object).
26+
* The Android system controls `RemoteViews` and limits the size and frequency of widget updates.
27+
28+
Remote Compose does **not** replace Jetpack Glance. Build most production widgets with Jetpack Glance for backwards compatibility and stable developer tools. Remote Compose widgets work best on Android 17 and higher devices.
29+
30+
> **Note:** Remote Compose is in alpha and the API surface can change. Send us your [feedback](https://issuetracker.google.com/issues/new?component=1984647&template=2233909).
31+
1632
## Canonical Widget Layout Samples
1733

1834
These [layouts](./src/main/java/com/example/platform/ui/appwidgets/glance/layout) demonstrate how

‎samples/user-interface/appwidgets/src/main/AndroidManifest.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@
200200
</receiver>
201201

202202
<!-- Remote Compose Android Pet Widget -->
203+
<activity
204+
android:name=".remote_compose.StoreActivity"
205+
android:exported="false"
206+
android:label="@string/android_pet_store_title" />
207+
203208
<receiver
204209
android:name=".remote_compose.AndroidPetWidget"
205210
android:exported="true"

‎samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt‎

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,50 +17,77 @@
1717
package com.example.platform.ui.appwidgets.remote_compose
1818

1919
import android.annotation.SuppressLint
20-
import android.appwidget.AppWidgetManager
2120
import android.content.Context
22-
import android.os.Build
23-
import androidx.annotation.RequiresApi
21+
import androidx.compose.remote.creation.compose.action.valueChange
2422
import androidx.compose.remote.creation.compose.layout.RemoteAlignment
2523
import androidx.compose.remote.creation.compose.layout.RemoteBox
24+
import androidx.compose.remote.creation.compose.layout.RemoteColumn
2625
import androidx.compose.remote.creation.compose.layout.RemoteComposable
2726
import androidx.compose.remote.creation.compose.layout.RemoteText
2827
import androidx.compose.remote.creation.compose.modifier.RemoteModifier
2928
import androidx.compose.remote.creation.compose.modifier.background
3029
import androidx.compose.remote.creation.compose.modifier.fillMaxSize
30+
import androidx.compose.remote.creation.compose.modifier.onTouchDown
3131
import androidx.compose.remote.creation.compose.state.rc
32+
import androidx.compose.remote.creation.compose.state.rememberMutableRemoteInt
33+
import androidx.compose.remote.creation.compose.state.ri
3234
import androidx.compose.remote.creation.compose.state.rs
35+
import androidx.compose.remote.creation.compose.state.selectIfGe
3336
import androidx.compose.remote.creation.compose.widgets.RemoteComposeWidget
3437
import androidx.compose.runtime.Composable
3538
import androidx.compose.ui.graphics.Color
3639

3740
@SuppressLint("RestrictedApiAndroidX", "RestrictedApi")
3841
class AndroidPetWidget : RemoteComposeWidget() {
3942

40-
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
41-
override fun onUpdate(
42-
context: Context,
43-
appWidgetManager: AppWidgetManager,
44-
appWidgetIds: IntArray
45-
) {
46-
super.onUpdate(context, appWidgetManager, appWidgetIds)
43+
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
44+
super.onDeleted(context, appWidgetIds)
4745
appWidgetIds.forEach { id ->
48-
widget.updateRemoteView(context, appWidgetManager, this, id, -1)
46+
BuddyPreferences.removeWidgetState(context, id)
4947
}
5048
}
5149

5250
@SuppressLint("RestrictedApiAndroidX", "RestrictedApi")
5351
@RemoteComposable
5452
@Composable
5553
override fun Content(context: Context, widgetId: Int) {
54+
val appContext = context.applicationContext ?: context
55+
val coins = BuddyPreferences.getCoinsForWidget(appContext, widgetId)
56+
57+
val petTaps = rememberMutableRemoteInt(0)
58+
val nextPetTaps = petTaps + 1.ri
59+
// Evaluates to 1 (visible) in the host's expression graph when petTaps >= 3,
60+
// or 0 (hidden) otherwise, allowing the coin button to appear after 3 taps
61+
// without waking the app process.
62+
val isClaimVisible = selectIfGe(petTaps, REQUIRED_TAPS_FOR_COIN.ri, 1.ri, 0.ri)
63+
5664
RemoteBox(
5765
modifier = RemoteModifier.fillMaxSize().background(Color.White.rc),
5866
contentAlignment = RemoteAlignment.Center
5967
) {
60-
RemoteText(
61-
text = "Android Pet Coming Soon".rs,
62-
color = Color.Black.rc
68+
RemoteColumn(
69+
modifier = RemoteModifier.onTouchDown(valueChange(petTaps, nextPetTaps)),
70+
horizontalAlignment = RemoteAlignment.CenterHorizontally
71+
) {
72+
RemoteText(
73+
text = "Android Pet".rs,
74+
color = Color.Black.rc
75+
)
76+
RemoteText(
77+
text = "Coins: $coins".rs,
78+
color = Color.DarkGray.rc
79+
)
80+
}
81+
82+
WidgetActionBar(
83+
context = appContext,
84+
widgetId = widgetId,
85+
isClaimVisible = isClaimVisible,
6386
)
6487
}
6588
}
89+
90+
companion object {
91+
const val REQUIRED_TAPS_FOR_COIN = 3
92+
}
6693
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2026 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.platform.ui.appwidgets.remote_compose
18+
19+
import android.appwidget.AppWidgetManager
20+
import android.content.ComponentName
21+
import android.content.Context
22+
import android.content.SharedPreferences
23+
import androidx.core.content.edit
24+
25+
/**
26+
* Snapshot of an Android Pet widget instance's state.
27+
*/
28+
data class WidgetBuddyState(
29+
val widgetId: Int,
30+
val coins: Int = 0,
31+
)
32+
33+
/**
34+
* Persists per-widget collected coins in [SharedPreferences].
35+
*/
36+
object BuddyPreferences {
37+
const val PREFS_NAME = "buddy_prefs"
38+
private const val KEY_COINS_PREFIX = "widget_coins_"
39+
40+
fun prefs(context: Context): SharedPreferences =
41+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
42+
43+
fun getCoinsForWidget(context: Context, widgetId: Int): Int =
44+
prefs(context).getInt("$KEY_COINS_PREFIX$widgetId", 0)
45+
46+
fun addCoinsForWidget(context: Context, widgetId: Int, amount: Int = 1): Int {
47+
val updated = (getCoinsForWidget(context, widgetId) + amount).coerceAtLeast(0)
48+
prefs(context).edit {
49+
putInt("$KEY_COINS_PREFIX$widgetId", updated)
50+
}
51+
return updated
52+
}
53+
54+
fun removeWidgetState(context: Context, widgetId: Int) {
55+
prefs(context).edit {
56+
remove("$KEY_COINS_PREFIX$widgetId")
57+
}
58+
}
59+
60+
fun getActiveBuddyStates(context: Context): List<WidgetBuddyState> {
61+
val manager = AppWidgetManager.getInstance(context)
62+
val component = ComponentName(context, AndroidPetWidget::class.java)
63+
return (manager.getAppWidgetIds(component) ?: IntArray(0))
64+
.filter { it != AppWidgetManager.INVALID_APPWIDGET_ID }
65+
.sorted()
66+
.map { id ->
67+
WidgetBuddyState(
68+
widgetId = id,
69+
coins = getCoinsForWidget(context, id),
70+
)
71+
}
72+
}
73+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2026 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.platform.ui.appwidgets.remote_compose
18+
19+
import android.annotation.SuppressLint
20+
import android.content.Context
21+
import androidx.compose.remote.creation.compose.layout.RemoteComposable
22+
import androidx.compose.remote.creation.compose.layout.RemoteImage
23+
import androidx.compose.remote.creation.compose.modifier.RemoteModifier
24+
import androidx.compose.remote.creation.compose.modifier.padding
25+
import androidx.compose.remote.creation.compose.modifier.size
26+
import androidx.compose.remote.creation.compose.modifier.visibility
27+
import androidx.compose.remote.creation.compose.state.RemoteInt
28+
import androidx.compose.remote.creation.compose.state.rb
29+
import androidx.compose.remote.creation.compose.state.rdp
30+
import androidx.compose.remote.creation.compose.state.rs
31+
import androidx.compose.remote.creation.compose.widgets.onClick
32+
import androidx.compose.runtime.Composable
33+
import androidx.compose.runtime.remember
34+
import androidx.compose.ui.graphics.asImageBitmap
35+
import androidx.core.content.ContextCompat
36+
import androidx.core.graphics.drawable.toBitmap
37+
import com.example.platform.ui.appwidgets.R
38+
39+
@SuppressLint("RestrictedApiAndroidX", "RestrictedApi")
40+
@RemoteComposable
41+
@Composable
42+
fun ClaimCreditButton(
43+
context: Context,
44+
widgetId: Int,
45+
isClaimVisible: RemoteInt,
46+
) {
47+
val imageBitmap = remember(context) {
48+
ContextCompat.getDrawable(context, R.drawable.claim_credit_button)!!.toBitmap().asImageBitmap()
49+
}
50+
51+
RemoteImage(
52+
remoteBitmap = imageBitmap.rb,
53+
contentDescription = "Claim Credit".rs,
54+
modifier = RemoteModifier
55+
.padding(end = 68.rdp, bottom = 12.rdp)
56+
.size(48.rdp)
57+
.visibility(isClaimVisible)
58+
.onClick {
59+
BuddyPreferences.addCoinsForWidget(context, widgetId)
60+
},
61+
)
62+
}

0 commit comments

Comments
 (0)