From 6d312fe7dfc68ae2dd2317bceac6c941612330d6 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Mon, 12 Oct 2026 22:51:02 +0000 Subject: [PATCH 01/21] 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 --- .../com/example/platform/app/SampleDemo.kt | 10 + .../appwidgets/build.gradle.kts | 24 + .../appwidgets/src/main/AndroidManifest.xml | 5 + .../platform/ui/appwidgets/AppWidgets.kt | 38 + .../remote_compose/AndroidPetWidget.kt | 148 +++- .../remote_compose/BuddyPreferences.kt | 190 +++++ .../remote_compose/ClaimCreditButton.kt | 120 +++ .../RemoteWidgetActionBridge.java | 69 ++ .../remote_compose/StoreActivity.kt | 290 +++++++ .../appwidgets/remote_compose/StoreButton.kt | 146 ++++ .../remote_compose/WidgetActionBar.kt | 53 ++ .../remote_compose/WidgetAssetProvider.kt | 61 ++ .../main/res/drawable/claim_credit_button.xml | 45 + .../src/main/res/drawable/shop_button.xml | 73 ++ .../src/main/res/values/strings.xml | 2 + .../remote_compose/AndroidPetWidgetTest.kt | 766 ++++++++++++++++++ 16 files changed, 2030 insertions(+), 10 deletions(-) create mode 100644 samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt create mode 100644 samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt create mode 100644 samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/RemoteWidgetActionBridge.java create mode 100644 samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt create mode 100644 samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt create mode 100644 samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt create mode 100644 samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt create mode 100644 samples/user-interface/appwidgets/src/main/res/drawable/claim_credit_button.xml create mode 100644 samples/user-interface/appwidgets/src/main/res/drawable/shop_button.xml create mode 100644 samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt diff --git a/app/src/main/java/com/example/platform/app/SampleDemo.kt b/app/src/main/java/com/example/platform/app/SampleDemo.kt index a2757752..747f73dd 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -69,6 +69,7 @@ import com.example.platform.storage.mediastore.SelectedPhotosAccessSample import com.example.platform.storage.photopicker.PhotoPickerSample import com.example.platform.storage.storageaccessframework.GetContentSample import com.example.platform.ui.appwidgets.AppWidgets +import com.example.platform.ui.appwidgets.remote_compose.StoreActivity import com.example.platform.ui.constraintlayout.AdvancedArrangementFragment import com.example.platform.ui.constraintlayout.AdvancedChainsFragment import com.example.platform.ui.constraintlayout.AspectRatioFragment @@ -651,6 +652,15 @@ val SAMPLE_DEMOS by lazy { } }, ), + ActivitySampleDemo( + id = "android-pet-store", + name = "Android Pet Store", + description = "Companion store for the Remote Compose Android Pet widget displaying active widget IDs and collected coin balances.", + documentation = "https://developer.android.com/develop/ui/views/appwidgets/overview", + apiSurface = UserInterfaceAppWidgetsApiSurface, + tags = listOf("App Widgets", "Remote Compose"), + content = StoreActivity::class.java, + ), ComposableSampleDemo( id = "constraintlayout-centering-views", name = "ConstraintLayout - 1. Centering Views", diff --git a/samples/user-interface/appwidgets/build.gradle.kts b/samples/user-interface/appwidgets/build.gradle.kts index 8df4100a..9125a5aa 100644 --- a/samples/user-interface/appwidgets/build.gradle.kts +++ b/samples/user-interface/appwidgets/build.gradle.kts @@ -42,6 +42,26 @@ android { } testOptions { targetSdk = 35 + unitTests { + isIncludeAndroidResources = true + all { + it.systemProperty("robolectric.conscryptMode", "OFF") + val robolectricM2Dir = file( + "${System.getProperty("user.home")}/.m2/repository/org/robolectric/android-all-instrumented/15-robolectric-12650502-i7" + ) + if (robolectricM2Dir.exists()) { + it.systemProperty("robolectric.offline", "true") + it.systemProperty("robolectric.dependency.dir", robolectricM2Dir.absolutePath) + } + val asmCacheDir = file("${System.getProperty("user.home")}/.gradle/caches/modules-2/files-2.1/org.ow2.asm") + if (asmCacheDir.exists()) { + val asm99Jars = fileTree(asmCacheDir) { + include("**/9.9/**/*.jar") + } + it.classpath = asm99Jars + it.classpath + } + } + } } } @@ -73,4 +93,8 @@ dependencies { debugImplementation(libs.androidx.glance.preview) debugImplementation(libs.androidx.glance.appwidget.preview) + + testImplementation(libs.junit4) + testImplementation("androidx.test:core-ktx:1.6.1") + testImplementation("org.robolectric:robolectric:4.14") } diff --git a/samples/user-interface/appwidgets/src/main/AndroidManifest.xml b/samples/user-interface/appwidgets/src/main/AndroidManifest.xml index 245f88eb..4065e852 100644 --- a/samples/user-interface/appwidgets/src/main/AndroidManifest.xml +++ b/samples/user-interface/appwidgets/src/main/AndroidManifest.xml @@ -200,6 +200,11 @@ + + ) { CanonicalLayoutsInfoCard() } + item { + AndroidPetStoreInfoCard() + } + // If the launcher does not support pinning request show a banner if (!widgetManager.isRequestPinAppWidgetSupported) { item { @@ -129,6 +134,39 @@ private fun CanonicalLayoutsInfoCard() { } } +@Composable +private fun AndroidPetStoreInfoCard() { + val context = LocalContext.current + Card( + modifier = Modifier + .fillMaxWidth(), + onClick = { StoreActivity.start(context) }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.padding(end = 8.dp)) { + Text( + text = stringResource(R.string.android_pet_store_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.android_pet_store_description), + style = MaterialTheme.typography.bodyMedium, + ) + } + Image( + painter = painterResource(id = R.drawable.shop_button), + contentDescription = stringResource(R.string.android_pet_store_title), + ) + } + } +} + private fun MutableList.removeCanonicalLayouts(): List { return this.toMutableList() .filter { !it.provider.className.startsWith("com.example.platform.ui.appwidgets.glance.layout") } diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt index 20025f33..b48d839e 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt @@ -19,33 +19,73 @@ package com.example.platform.ui.appwidgets.remote_compose import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.Context +import android.content.Intent +import android.graphics.Bitmap import android.os.Build +import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi +import androidx.compose.remote.creation.compose.action.valueChange import androidx.compose.remote.creation.compose.layout.RemoteAlignment import androidx.compose.remote.creation.compose.layout.RemoteBox +import androidx.compose.remote.creation.compose.layout.RemoteCanvas +import androidx.compose.remote.creation.compose.layout.RemoteColumn import androidx.compose.remote.creation.compose.layout.RemoteComposable import androidx.compose.remote.creation.compose.layout.RemoteText import androidx.compose.remote.creation.compose.modifier.RemoteModifier import androidx.compose.remote.creation.compose.modifier.background import androidx.compose.remote.creation.compose.modifier.fillMaxSize +import androidx.compose.remote.creation.compose.modifier.onTouchDown import androidx.compose.remote.creation.compose.state.rc +import androidx.compose.remote.creation.compose.state.rememberMutableRemoteInt +import androidx.compose.remote.creation.compose.state.ri import androidx.compose.remote.creation.compose.state.rs +import androidx.compose.remote.creation.compose.state.selectIfGe import androidx.compose.remote.creation.compose.widgets.RemoteComposeWidget import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color @SuppressLint("RestrictedApiAndroidX", "RestrictedApi") class AndroidPetWidget : RemoteComposeWidget() { - @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + override fun onReceive(context: Context, intent: Intent) { + if ( + intent.action == AppWidgetManager.ACTION_APPWIDGET_UPDATE && + intent.getBooleanExtra(EXTRA_ALREADY_UPDATED, false) + ) { + return + } + val preservedActions = RemoteWidgetActionBridge.snapshotLambdaActions() + runCatching { + super.onReceive(context, intent) + } + preservedActions.putAll(RemoteWidgetActionBridge.snapshotLambdaActions()) + RemoteWidgetActionBridge.restoreLambdaActions(preservedActions) + } + override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { - super.onUpdate(context, appWidgetManager, appWidgetIds) + val preservedActions = RemoteWidgetActionBridge.snapshotLambdaActions() + for (id in appWidgetIds) { + if (id != AppWidgetManager.INVALID_APPWIDGET_ID) { + BuddyPreferences.ensureWidgetRegistered(context, id) + } + runCatching { + super.onUpdate(context, appWidgetManager, intArrayOf(id)) + } + preservedActions.putAll(RemoteWidgetActionBridge.snapshotLambdaActions()) + } + RemoteWidgetActionBridge.restoreLambdaActions(preservedActions) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) appWidgetIds.forEach { id -> - widget.updateRemoteView(context, appWidgetManager, this, id, -1) + BuddyPreferences.removeWidgetState(context, id) } } @@ -53,14 +93,102 @@ class AndroidPetWidget : RemoteComposeWidget() { @RemoteComposable @Composable override fun Content(context: Context, widgetId: Int) { - RemoteBox( - modifier = RemoteModifier.fillMaxSize().background(Color.White.rc), - contentAlignment = RemoteAlignment.Center + RemoteWidgetActionBridge.resetLambdaCounter() + val appContext = context.applicationContext ?: context + val claimCreditIconBitmap = remember(appContext) { + WidgetAssetProvider.claimCreditBitmap(appContext) + } + val storeIconBitmap = remember(appContext) { + WidgetAssetProvider.storeBitmap(appContext) + } + val coins = BuddyPreferences.getCoinsForWidget(appContext, widgetId) + + val zeroTaps = 0.ri + val petTaps = rememberMutableRemoteInt(0) + val nextPetTaps = petTaps + 1.ri + val isClaimVisible = selectIfGe(petTaps, REQUIRED_TAPS_FOR_COIN.ri, 1.ri, 0.ri) + + CompositionLocalProvider( + RemoteWidgetActionBridge.getLocalWidget() provides + RemoteWidgetActionBridge.createWidgetInformation(widgetId) ) { - RemoteText( - text = "Android Pet Coming Soon".rs, - color = Color.Black.rc - ) + RemoteBox( + modifier = RemoteModifier.fillMaxSize().background(Color.White.rc), + contentAlignment = RemoteAlignment.Center + ) { + // Materialize RemoteInt expression IDs in the active creation state before + // action modifiers serialize them into nested action lists. + RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { + zeroTaps.getIdForCreationState(remoteComposeCreationState) + petTaps.getIdForCreationState(remoteComposeCreationState) + nextPetTaps.getIdForCreationState(remoteComposeCreationState) + isClaimVisible.getIdForCreationState(remoteComposeCreationState) + } + + // Pet interactive touch target (placed below WidgetActionBar so button clicks + // on the action bar are not intercepted by the pet touch target). + RemoteBox( + modifier = RemoteModifier + .fillMaxSize() + .onTouchDown(valueChange(petTaps, nextPetTaps)), + contentAlignment = RemoteAlignment.Center + ) { + RemoteColumn( + horizontalAlignment = RemoteAlignment.CenterHorizontally + ) { + RemoteText( + text = "Android Pet".rs, + color = Color.Black.rc + ) + RemoteText( + text = "Coins: $coins".rs, + color = Color.DarkGray.rc + ) + } + } + + WidgetActionBar( + context = appContext, + widgetId = widgetId, + claimCreditIconBitmap = claimCreditIconBitmap, + storeIconBitmap = storeIconBitmap, + isClaimVisible = isClaimVisible, + petTaps = petTaps, + resetValue = zeroTaps, + ) + } + } + } + + companion object { + const val REQUIRED_TAPS_FOR_COIN = 3 + private const val EXTRA_ALREADY_UPDATED = + "com.example.platform.ui.appwidgets.remote_compose.EXTRA_ALREADY_UPDATED" + + fun decodeBitmapFromResource(context: Context, @DrawableRes resId: Int): Bitmap = + WidgetAssetProvider.decodeBitmap(context, resId) + + fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + ) { + AndroidPetWidget().onUpdate(context, appWidgetManager, intArrayOf(appWidgetId)) + val intent = Intent(context, AndroidPetWidget::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId)) + putExtra(EXTRA_ALREADY_UPDATED, true) + } + context.sendBroadcast(intent) + } + + fun updateAppWidget(context: Context, appWidgetId: Int) { + val manager = AppWidgetManager.getInstance(context) + updateAppWidget(context, manager, appWidgetId) } } } + +fun refreshAndroidPetWidget(context: Context, widgetId: Int) { + AndroidPetWidget.updateAppWidget(context, widgetId) +} diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt new file mode 100644 index 00000000..1bfe55a7 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt @@ -0,0 +1,190 @@ +/* + * Copyright 2026 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.platform.ui.appwidgets.remote_compose + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.SharedPreferences + +/** + * Snapshot of an Android Pet widget instance's state. + */ +data class WidgetBuddyState( + val widgetId: Int, + val tapsCount: Int = 0, + val coins: Int = 0, +) { + val appWidgetId: Int + get() = widgetId +} + +/** + * Persists per-widget tap counts and collected coins in [SharedPreferences]. + */ +object BuddyPreferences { + const val PREFS_NAME = "buddy_prefs" + private const val KEY_TAPS_PREFIX = "taps_count_" + private const val KEY_COINS_PREFIX = "widget_coins_" + + fun prefs(context: Context): SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun getTapsCount(context: Context, widgetId: Int): Int { + val p = prefs(context) + val tapsKey = "$KEY_TAPS_PREFIX$widgetId" + return if (p.contains(tapsKey)) { + p.getInt(tapsKey, 0) + } else { + p.getInt("$KEY_COINS_PREFIX$widgetId", 0) + } + } + + fun saveTapsCount(context: Context, widgetId: Int, count: Int) { + prefs(context).edit() + .putInt("$KEY_TAPS_PREFIX$widgetId", count.coerceAtLeast(0)) + .apply() + } + + fun addTapsCount(context: Context, widgetId: Int, delta: Int = 1): Int { + val updated = (getTapsCount(context, widgetId) + delta).coerceAtLeast(0) + saveTapsCount(context, widgetId, updated) + return updated + } + + fun getCoinsForWidget(context: Context, widgetId: Int): Int { + val p = prefs(context) + val coinsKey = "$KEY_COINS_PREFIX$widgetId" + return if (p.contains(coinsKey)) { + p.getInt(coinsKey, 0) + } else { + p.getInt("$KEY_TAPS_PREFIX$widgetId", 0) + } + } + + fun saveCoinsForWidget(context: Context, widgetId: Int, coins: Int) { + prefs(context).edit() + .putInt("$KEY_COINS_PREFIX$widgetId", coins.coerceAtLeast(0)) + .apply() + } + + fun addCoinsForWidget(context: Context, widgetId: Int, amount: Int = 1): Int { + val updated = (getCoinsForWidget(context, widgetId) + amount).coerceAtLeast(0) + saveCoinsForWidget(context, widgetId, updated) + return updated + } + + fun ensureWidgetRegistered(context: Context, widgetId: Int) { + if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return + val p = prefs(context) + val coinsKey = "$KEY_COINS_PREFIX$widgetId" + val tapsKey = "$KEY_TAPS_PREFIX$widgetId" + if (!p.contains(coinsKey) && !p.contains(tapsKey)) { + p.edit().putInt(coinsKey, 0).apply() + } + } + + fun getWidgetBuddyState(context: Context, widgetId: Int): WidgetBuddyState = + WidgetBuddyState( + widgetId = widgetId, + tapsCount = getTapsCount(context, widgetId), + coins = getCoinsForWidget(context, widgetId), + ) + + fun removeWidgetState(context: Context, widgetId: Int) { + prefs(context).edit() + .remove("$KEY_TAPS_PREFIX$widgetId") + .remove("$KEY_COINS_PREFIX$widgetId") + .apply() + } + + /** + * Returns all active widget IDs from [AppWidgetManager] combined with any widget IDs + * that have persisted state in [BuddyPreferences]. + */ + fun getActiveWidgetIds(context: Context): List { + val managerIds = runCatching { + val manager = AppWidgetManager.getInstance(context) + val component = ComponentName(context, AndroidPetWidget::class.java) + manager.getAppWidgetIds(component).toList() + }.getOrDefault(emptyList()) + + val prefIds = prefs(context).all.keys.mapNotNull { key -> + when { + key.startsWith(KEY_COINS_PREFIX) -> key.removePrefix(KEY_COINS_PREFIX).toIntOrNull() + key.startsWith(KEY_TAPS_PREFIX) -> key.removePrefix(KEY_TAPS_PREFIX).toIntOrNull() + else -> null + } + } + + return (managerIds + prefIds) + .filter { it != AppWidgetManager.INVALID_APPWIDGET_ID } + .distinct() + .sorted() + } + + fun getActiveBuddyStates(context: Context): List = + getActiveWidgetIds(context).map { id -> + getWidgetBuddyState(context, id) + } + + fun getActiveBuddies(context: Context): List = + getActiveBuddyStates(context) +} + +/** + * Repository wrapper over [BuddyPreferences] and [AppWidgetManager] for querying and updating + * Android Pet widget state. + */ +class BuddyRepository(context: Context) { + private val appContext = context.applicationContext ?: context + + fun getActiveWidgetIds(): List = BuddyPreferences.getActiveWidgetIds(appContext) + + fun getActiveBuddies(): List = BuddyPreferences.getActiveWidgetIds(appContext) + + fun getActiveBuddyStates(): List = + BuddyPreferences.getActiveBuddyStates(appContext) + + fun getWidgetBuddyState(widgetId: Int): WidgetBuddyState = + BuddyPreferences.getWidgetBuddyState(appContext, widgetId) + + fun getCoinsForWidget(widgetId: Int): Int = + BuddyPreferences.getCoinsForWidget(appContext, widgetId) + + fun addCoinsForWidget(widgetId: Int, amount: Int = 1): Int = + BuddyPreferences.addCoinsForWidget(appContext, widgetId, amount) + + fun playWithBuddy(widgetId: Int): Int { + val updated = BuddyPreferences.addCoinsForWidget(appContext, widgetId, 1) + BuddyPreferences.saveTapsCount(appContext, widgetId, updated) + AndroidPetWidget.updateAppWidget(appContext, widgetId) + return updated + } + + fun getTapsCount(widgetId: Int): Int = + BuddyPreferences.getTapsCount(appContext, widgetId) + + fun saveTapsCount(widgetId: Int, count: Int) = + BuddyPreferences.saveTapsCount(appContext, widgetId, count) + + fun removeWidget(widgetId: Int) = + BuddyPreferences.removeWidgetState(appContext, widgetId) + + fun removeWidgetState(widgetId: Int) = + BuddyPreferences.removeWidgetState(appContext, widgetId) +} diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt new file mode 100644 index 00000000..4f934661 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026 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.platform.ui.appwidgets.remote_compose + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.remote.creation.compose.action.combinedAction +import androidx.compose.remote.creation.compose.action.valueChange +import androidx.compose.remote.creation.compose.layout.RemoteAlignment +import androidx.compose.remote.creation.compose.layout.RemoteBox +import androidx.compose.remote.creation.compose.layout.RemoteCanvas +import androidx.compose.remote.creation.compose.layout.RemoteComposable +import androidx.compose.remote.creation.compose.modifier.RemoteModifier +import androidx.compose.remote.creation.compose.modifier.clickable +import androidx.compose.remote.creation.compose.modifier.fillMaxSize +import androidx.compose.remote.creation.compose.modifier.padding +import androidx.compose.remote.creation.compose.modifier.size +import androidx.compose.remote.creation.compose.modifier.visibility +import androidx.compose.remote.creation.compose.state.MutableRemoteInt +import androidx.compose.remote.creation.compose.state.RemoteInt +import androidx.compose.remote.creation.compose.state.rb +import androidx.compose.remote.creation.compose.state.rdp +import androidx.compose.remote.creation.compose.state.ri +import androidx.compose.remote.creation.compose.widgets.onClick +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.asImageBitmap + +@SuppressLint("RestrictedApiAndroidX", "RestrictedApi") +@RemoteComposable +@Composable +fun ClaimCreditButton( + context: Context, + widgetId: Int, + claimCreditIconBitmap: Bitmap? = null, + isClaimVisible: RemoteInt? = null, + petTaps: MutableRemoteInt? = null, + resetValue: RemoteInt = 0.ri, +) { + val bitmap = claimCreditIconBitmap ?: remember { + WidgetAssetProvider.claimCreditBitmap(context) + } + + val claimAction: () -> Unit = { + val updated = BuddyPreferences.addCoinsForWidget(context, widgetId, 1) + BuddyPreferences.saveTapsCount(context, widgetId, updated) + } + + CompositionLocalProvider( + RemoteWidgetActionBridge.getLocalWidget() provides + RemoteWidgetActionBridge.createWidgetInformation(widgetId) + ) { + RemoteBox( + contentAlignment = RemoteAlignment.BottomEnd, + modifier = RemoteModifier.fillMaxSize(), + ) { + val baseModifier = if (isClaimVisible != null) { + RemoteModifier + .padding(end = 68.rdp, bottom = 12.rdp) + .size(48.rdp) + .visibility(isClaimVisible) + } else { + RemoteModifier + .padding(end = 68.rdp, bottom = 12.rdp) + .size(48.rdp) + } + + val buttonModifier = if (petTaps != null) { + baseModifier.clickable( + combinedAction( + valueChange(petTaps, resetValue), + RemoteWidgetActionBridge.createLambdaAction(widgetId, claimAction), + ) + ) + } else { + baseModifier.onClick(claimAction) + } + + RemoteBox(modifier = buttonModifier) { + RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { + drawScaledBitmap( + image = bitmap.asImageBitmap().rb, + contentDescription = "Claim Credit", + ) + } + } + } + } +} + +@SuppressLint("RestrictedApiAndroidX", "RestrictedApi") +@RemoteComposable +@Composable +fun ClaimCreditButton( + context: Context, + widgetId: Int, + isClaimVisible: RemoteInt, + claimBitmap: Bitmap? = null, +): Unit = ClaimCreditButton( + context = context, + widgetId = widgetId, + claimCreditIconBitmap = claimBitmap, + isClaimVisible = isClaimVisible, +) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/RemoteWidgetActionBridge.java b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/RemoteWidgetActionBridge.java new file mode 100644 index 00000000..0b5a6ee0 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/RemoteWidgetActionBridge.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 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.platform.ui.appwidgets.remote_compose; + +import android.annotation.SuppressLint; +import androidx.compose.remote.creation.compose.action.Action; +import androidx.compose.remote.creation.compose.widgets.WidgetInformation; +import androidx.compose.remote.creation.compose.widgets.WidgetInformationKt; +import androidx.compose.remote.creation.compose.widgets.WidgetLambdaAction; +import androidx.compose.runtime.ProvidableCompositionLocal; +import kotlin.Unit; +import kotlin.jvm.functions.Function0; + +/** + * Bridge for combining a host {@link WidgetLambdaAction} with local in-document Remote Compose + * state actions inside a single click modifier. + */ +@SuppressLint({"RestrictedApiAndroidX", "RestrictedApi"}) +public final class RemoteWidgetActionBridge { + private RemoteWidgetActionBridge() {} + + public static Action createLambdaAction(int widgetId, Function0 action) { + return new WidgetLambdaAction(widgetId, action); + } + + public static ProvidableCompositionLocal getLocalWidget() { + return WidgetInformationKt.getLocalWidget(); + } + + public static WidgetInformation createWidgetInformation(int widgetId) { + return new WidgetInformation(widgetId); + } + + public static void clearLambdaActions() { + WidgetLambdaAction.Companion.clear(); + } + + public static void resetLambdaCounter() { + WidgetLambdaAction.Companion.setCounter(0); + } + + public static java.util.Map snapshotLambdaActions() { + return new java.util.HashMap<>(WidgetLambdaAction.Companion.getMap()); + } + + public static void restoreLambdaActions(java.util.Map snapshot) { + if (snapshot != null) { + WidgetLambdaAction.Companion.getMap().putAll(snapshot); + } + } + + public static void runLambdaAction(int actionId) { + WidgetLambdaAction.Companion.run(actionId); + } +} diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt new file mode 100644 index 00000000..fdc618d4 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -0,0 +1,290 @@ +/* + * Copyright 2026 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.platform.ui.appwidgets.remote_compose + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.example.platform.ui.appwidgets.R + +/** + * Companion Store activity for the Remote Compose [AndroidPetWidget]. + * Displays each active widget's ID and collected coin balance. + */ +class StoreActivity : ComponentActivity() { + + private var selectedWidgetId by mutableIntStateOf(AppWidgetManager.INVALID_APPWIDGET_ID) + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + selectedWidgetId = extractWidgetId(intent) + + setContent { + MaterialTheme { + StoreScreen( + selectedWidgetId = selectedWidgetId, + onBack = { finish() }, + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + selectedWidgetId = extractWidgetId(intent) + } + + private fun extractWidgetId(intent: Intent?): Int { + if (intent == null) return AppWidgetManager.INVALID_APPWIDGET_ID + val fromStandardExtra = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) + if (fromStandardExtra != AppWidgetManager.INVALID_APPWIDGET_ID) { + return fromStandardExtra + } + return intent.getIntExtra(EXTRA_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + } + + companion object { + const val EXTRA_WIDGET_ID = "appWidgetId" + + fun start( + context: Context, + widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID, + ) { + val intent = Intent(context, StoreActivity::class.java).apply { + if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) + putExtra(EXTRA_WIDGET_ID, widgetId) + } + } + context.startActivity(intent) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StoreScreen( + selectedWidgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID, + buddyStates: List? = null, + onBack: (() -> Unit)? = null, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var loadedStates by remember(context, selectedWidgetId) { + mutableStateOf(loadWidgetStates(context, selectedWidgetId)) + } + + DisposableEffect(lifecycleOwner, context, selectedWidgetId, buddyStates) { + val prefs = BuddyPreferences.prefs(context) + val prefListener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> + if (buddyStates == null) { + loadedStates = loadWidgetStates(context, selectedWidgetId) + } + } + prefs.registerOnSharedPreferenceChangeListener(prefListener) + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && buddyStates == null) { + loadedStates = loadWidgetStates(context, selectedWidgetId) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + prefs.unregisterOnSharedPreferenceChangeListener(prefListener) + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val activeStates = buddyStates ?: loadedStates + + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Image( + painter = painterResource(id = R.drawable.shop_button), + contentDescription = null, + modifier = Modifier.size(28.dp), + ) + Text(text = "Android Pet Store") + } + }, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (activeStates.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "No active Android Pet widgets", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Pin an Android Pet widget to your home screen and tap the pet to collect coins.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } else { + items(activeStates, key = { it.widgetId }) { state -> + WidgetCoinCard( + state = state, + isSelected = state.widgetId == selectedWidgetId, + ) + } + } + } + } +} + +@Composable +private fun WidgetCoinCard( + state: WidgetBuddyState, + isSelected: Boolean, +) { + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + Card( + modifier = Modifier + .fillMaxWidth() + .testTag("widget_card_${state.widgetId}"), + colors = CardDefaults.cardColors(containerColor = containerColor), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Widget ID: ${state.widgetId}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (isSelected) { + Text( + text = "Active Widget", + style = MaterialTheme.typography.labelMedium, + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Image( + painter = painterResource(id = R.drawable.claim_credit_button), + contentDescription = null, + modifier = Modifier.size(28.dp), + ) + Text( + text = "Coins: ${state.coins}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + +private fun loadWidgetStates( + context: Context, + selectedWidgetId: Int, +): List { + val states = BuddyPreferences.getActiveBuddyStates(context).toMutableList() + if ( + selectedWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID && + states.none { it.widgetId == selectedWidgetId } + ) { + states.add( + WidgetBuddyState( + widgetId = selectedWidgetId, + tapsCount = BuddyPreferences.getTapsCount(context, selectedWidgetId), + coins = BuddyPreferences.getCoinsForWidget(context, selectedWidgetId), + ) + ) + } + return states.sortedBy { it.widgetId } +} diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt new file mode 100644 index 00000000..d26198de --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2026 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.platform.ui.appwidgets.remote_compose + +import android.annotation.SuppressLint +import android.app.ActivityOptions +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import androidx.compose.remote.creation.compose.layout.RemoteAlignment +import androidx.compose.remote.creation.compose.layout.RemoteBox +import androidx.compose.remote.creation.compose.layout.RemoteCanvas +import androidx.compose.remote.creation.compose.layout.RemoteComposable +import androidx.compose.remote.creation.compose.modifier.RemoteModifier +import androidx.compose.remote.creation.compose.modifier.fillMaxSize +import androidx.compose.remote.creation.compose.modifier.padding +import androidx.compose.remote.creation.compose.modifier.size +import androidx.compose.remote.creation.compose.modifier.visibility +import androidx.compose.remote.creation.compose.state.RemoteInt +import androidx.compose.remote.creation.compose.state.rb +import androidx.compose.remote.creation.compose.state.rdp +import androidx.compose.remote.creation.compose.widgets.onClick +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.asImageBitmap + +@SuppressLint("RestrictedApiAndroidX", "RestrictedApi") +@RemoteComposable +@Composable +fun StoreButton( + context: Context, + widgetId: Int, + storeIconBitmap: Bitmap? = null, + isStoreVisible: RemoteInt? = null, +) { + val bitmap = storeIconBitmap ?: remember { + WidgetAssetProvider.storeBitmap(context) + } + + CompositionLocalProvider( + RemoteWidgetActionBridge.getLocalWidget() provides + RemoteWidgetActionBridge.createWidgetInformation(widgetId) + ) { + RemoteBox( + contentAlignment = RemoteAlignment.BottomEnd, + modifier = RemoteModifier.fillMaxSize(), + ) { + val buttonModifier = if (isStoreVisible != null) { + RemoteModifier + .padding(12.rdp) + .size(48.rdp) + .visibility(isStoreVisible) + } else { + RemoteModifier + .padding(12.rdp) + .size(48.rdp) + } + + RemoteBox( + modifier = buttonModifier + .onClick { + launchStoreActivity(context, widgetId) + }, + ) { + RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { + drawScaledBitmap( + image = bitmap.asImageBitmap().rb, + contentDescription = "Store", + ) + } + } + } + } +} + +@SuppressLint("RestrictedApiAndroidX", "RestrictedApi") +@RemoteComposable +@Composable +fun StoreButton( + isStoreVisible: RemoteInt, + context: Context, + widgetId: Int, +): Unit = StoreButton( + context = context, + widgetId = widgetId, + isStoreVisible = isStoreVisible, +) + +@SuppressLint("RestrictedApiAndroidX", "RestrictedApi") +@RemoteComposable +@Composable +fun StoreButton( + context: Context, + widgetId: Int, + isStoreVisible: RemoteInt, + iconBitmap: Bitmap? = null, +): Unit = StoreButton( + context = context, + widgetId = widgetId, + storeIconBitmap = iconBitmap, + isStoreVisible = isStoreVisible, +) + +internal fun launchStoreActivity(context: Context, widgetId: Int) { + val intent = Intent(context, StoreActivity::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) + putExtra(StoreActivity.EXTRA_WIDGET_ID, widgetId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val options = ActivityOptions.makeBasic().apply { + if (Build.VERSION.SDK_INT >= 36) { + setPendingIntentCreatorBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED + ) + } else { + @Suppress("DEPRECATION") + setPendingIntentBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED + ) + } + } + context.startActivity(intent, options.toBundle()) + } else { + context.startActivity(intent) + } +} diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt new file mode 100644 index 00000000..627ea342 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 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.platform.ui.appwidgets.remote_compose + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.remote.creation.compose.layout.RemoteComposable +import androidx.compose.remote.creation.compose.state.MutableRemoteInt +import androidx.compose.remote.creation.compose.state.RemoteInt +import androidx.compose.remote.creation.compose.state.ri +import androidx.compose.runtime.Composable + +@SuppressLint("RestrictedApiAndroidX", "RestrictedApi") +@RemoteComposable +@Composable +fun WidgetActionBar( + context: Context, + widgetId: Int, + claimCreditIconBitmap: Bitmap? = null, + storeIconBitmap: Bitmap? = null, + isClaimVisible: RemoteInt? = null, + petTaps: MutableRemoteInt? = null, + resetValue: RemoteInt = 0.ri, +) { + ClaimCreditButton( + context = context, + widgetId = widgetId, + claimCreditIconBitmap = claimCreditIconBitmap, + isClaimVisible = isClaimVisible, + petTaps = petTaps, + resetValue = resetValue, + ) + StoreButton( + context = context, + widgetId = widgetId, + storeIconBitmap = storeIconBitmap, + ) +} diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt new file mode 100644 index 00000000..c8afd381 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 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.platform.ui.appwidgets.remote_compose + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import com.example.platform.ui.appwidgets.R +import java.util.concurrent.ConcurrentHashMap + +/** + * Decodes and caches drawable resources into [Bitmap] instances for use in Remote Compose widgets. + */ +object WidgetAssetProvider { + private val bitmapCache = ConcurrentHashMap() + + fun claimCreditBitmap(context: Context): Bitmap = + decodeBitmap(context, R.drawable.claim_credit_button) + + fun storeBitmap(context: Context): Bitmap = + decodeBitmap(context, R.drawable.shop_button) + + fun decodeBitmap(context: Context, @DrawableRes resId: Int): Bitmap { + val appContext = context.applicationContext ?: context + return bitmapCache.getOrPut(resId) { + decodeDrawableToBitmap(appContext, resId) + } + } + + fun clearCache() { + bitmapCache.clear() + } + + private fun decodeDrawableToBitmap(context: Context, @DrawableRes resId: Int): Bitmap { + val drawable = ContextCompat.getDrawable(context, resId) + ?: return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + val width = drawable.intrinsicWidth.coerceAtLeast(1) + val height = drawable.intrinsicHeight.coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap + } +} diff --git a/samples/user-interface/appwidgets/src/main/res/drawable/claim_credit_button.xml b/samples/user-interface/appwidgets/src/main/res/drawable/claim_credit_button.xml new file mode 100644 index 00000000..9da422f5 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/res/drawable/claim_credit_button.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/user-interface/appwidgets/src/main/res/drawable/shop_button.xml b/samples/user-interface/appwidgets/src/main/res/drawable/shop_button.xml new file mode 100644 index 00000000..dbd8600c --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/res/drawable/shop_button.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/user-interface/appwidgets/src/main/res/values/strings.xml b/samples/user-interface/appwidgets/src/main/res/values/strings.xml index b8af365a..2b97d9e8 100644 --- a/samples/user-interface/appwidgets/src/main/res/values/strings.xml +++ b/samples/user-interface/appwidgets/src/main/res/values/strings.xml @@ -30,6 +30,8 @@ Android Pet An animated interactive pet widget powered by Remote Compose. Remote Compose widgets work on Android 17 and higher devices. Use an Android 17 device. + Android Pet Store + View active Android Pet widgets and their collected coins. LayoutSamples Settings diff --git a/samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt b/samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt new file mode 100644 index 00000000..7b5571d8 --- /dev/null +++ b/samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt @@ -0,0 +1,766 @@ +/* + * Copyright 2026 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.platform.ui.appwidgets.remote_compose + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import androidx.compose.remote.core.CoreDocument +import androidx.compose.remote.core.PaintContext +import androidx.compose.remote.core.RcPlatformServices +import androidx.compose.remote.core.RemoteComposeBuffer +import androidx.compose.remote.core.RemoteContext +import androidx.compose.remote.core.VariableSupport +import androidx.compose.remote.core.operations.Theme +import androidx.compose.remote.core.operations.layout.Component +import androidx.compose.remote.core.operations.layout.LayoutComponent +import androidx.compose.remote.core.operations.paint.PaintBundle +import androidx.compose.remote.creation.compose.capture.captureSingleRemoteDocument +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.node.RootForTest +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import java.io.ByteArrayInputStream +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.ConscryptMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@ConscryptMode(ConscryptMode.Mode.OFF) +class AndroidPetWidgetTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + BuddyPreferences.prefs(context).edit().clear().commit() + WidgetAssetProvider.clearCache() + RemoteWidgetActionBridge.clearLambdaActions() + } + + @After + fun tearDown() { + BuddyPreferences.prefs(context).edit().clear().commit() + WidgetAssetProvider.clearCache() + } + + @Test + fun petTaps_coinButtonHiddenUntilThreeTaps_andResetsWhenClaimed() = runBlocking { + val widgetId = 42 + val doc = captureWidgetDocument(widgetId) + val remoteContext = TestRemoteContext(widgetId) + + doc.initializeContext(remoteContext) + doc.paint(remoteContext, Theme.UNSPECIFIED) + + val root = doc.rootLayoutComponent + assertNotNull("Expected RootLayoutComponent in CoreDocument", root) + + // StoreButton is 48x48 with 12dp padding at BottomEnd (outer box: x=228, y=228, w=72, h=72) + val storeBox = findComponentByBounds(root!!, x = 228f, y = 228f, w = 72f, h = 72f) + assertNotNull("Expected StoreButton at BottomEnd (228, 228, 72x72) in:\n${describeTree(root)}", storeBox) + assertTrue("StoreButton should always be visible", storeBox!!.isVisible) + + // ClaimCreditButton is 48x48 with end=68dp, bottom=12dp at BottomEnd (outer box: x=184, y=240, w=116, h=60) + val claimBox = findComponentByBounds(root, x = 184f, y = 240f, w = 116f, h = 60f) + assertNotNull("Expected ClaimCreditButton at BottomEnd (184, 240, 116x60) in:\n${describeTree(root)}", claimBox) + + // 0 taps: coin button is hidden + assertFalse("Coin button must be invisible at 0 taps", claimBox!!.isVisible) + + // Clicking coin button while invisible does nothing + doc.onClick(remoteContext, 208f, 264f) + assertEquals(0, BuddyPreferences.getCoinsForWidget(context, widgetId)) + + // Tap 1 on pet: coin button still invisible + doc.touchDown(remoteContext, 150f, 150f) + doc.paint(remoteContext, Theme.UNSPECIFIED) + doc.paint(remoteContext, Theme.UNSPECIFIED) + assertFalse("Coin button must remain invisible after 1 tap", claimBox.isVisible) + + // Tap 2 on pet: coin button still invisible + doc.touchDown(remoteContext, 150f, 150f) + doc.paint(remoteContext, Theme.UNSPECIFIED) + doc.paint(remoteContext, Theme.UNSPECIFIED) + assertFalse("Coin button must remain invisible after 2 taps", claimBox.isVisible) + + // Tap 3 on pet: coin button becomes visible locally without waking app process + doc.touchDown(remoteContext, 150f, 150f) + doc.paint(remoteContext, Theme.UNSPECIFIED) + doc.paint(remoteContext, Theme.UNSPECIFIED) + assertTrue( + "Coin button must become visible on the 3rd tap", + claimBox.isVisible, + ) + assertEquals( + "No host actions should fire during local pet taps", + 0, + remoteContext.triggeredActionIds.size, + ) + + // Tap the coin button to claim credit: + // 1. Persists +1 coin in BuddyPreferences + // 2. Resets local tap state so coin disappears again until another 3 taps + doc.onClick(remoteContext, 208f, 264f) + shadowOf(Looper.getMainLooper()).idle() + doc.paint(remoteContext, Theme.UNSPECIFIED) + doc.paint(remoteContext, Theme.UNSPECIFIED) + + assertEquals(1, BuddyPreferences.getCoinsForWidget(context, widgetId)) + assertFalse("Coin button must hide again after claiming", claimBox.isVisible) + + // Another 3 taps makes the coin button reappear and allows claiming a 2nd coin + repeat(2) { + doc.touchDown(remoteContext, 150f, 150f) + doc.paint(remoteContext, Theme.UNSPECIFIED) + doc.paint(remoteContext, Theme.UNSPECIFIED) + assertFalse(claimBox.isVisible) + } + doc.touchDown(remoteContext, 150f, 150f) + doc.paint(remoteContext, Theme.UNSPECIFIED) + doc.paint(remoteContext, Theme.UNSPECIFIED) + assertTrue("Coin button must reappear after another 3 taps", claimBox.isVisible) + + doc.onClick(remoteContext, 208f, 264f) + shadowOf(Looper.getMainLooper()).idle() + doc.paint(remoteContext, Theme.UNSPECIFIED) + doc.paint(remoteContext, Theme.UNSPECIFIED) + assertEquals(2, BuddyPreferences.getCoinsForWidget(context, widgetId)) + assertFalse(claimBox.isVisible) + } + + @Test + fun storeButton_clickLaunchesStoreActivityWithWidgetId() = runBlocking { + val widgetId = 42 + val doc = captureWidgetDocument(widgetId) + val remoteContext = TestRemoteContext(widgetId) + + doc.initializeContext(remoteContext) + doc.paint(remoteContext, Theme.UNSPECIFIED) + + // Click StoreButton center (x = 240 + 24 = 264, y = 240 + 24 = 264) + doc.onClick(remoteContext, 264f, 264f) + + val startedIntent = shadowOf(context as android.app.Application).nextStartedActivity + assertNotNull("Expected StoreButton click to start StoreActivity", startedIntent) + assertEquals(StoreActivity::class.java.name, startedIntent.component?.className) + assertEquals( + widgetId, + startedIntent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ), + ) + assertEquals( + widgetId, + startedIntent.getIntExtra( + StoreActivity.EXTRA_WIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ), + ) + } + + @Test + fun storeActivity_singleWidget_displaysWidgetIdAndCoins() { + BuddyPreferences.saveCoinsForWidget(context, 42, 3) + + val intent = Intent(context, StoreActivity::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 42) + } + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + val texts = collectSemanticsTexts(activity) + assertTrue("Expected 'Widget ID: 42' in $texts", texts.contains("Widget ID: 42")) + assertTrue("Expected 'Coins: 3' in $texts", texts.contains("Coins: 3")) + assertTrue("Expected 'Active Widget' in $texts", texts.contains("Active Widget")) + } + } + } + + @Test + fun storeActivity_multipleWidgets_displaysAllActiveWidgetsAndBalances() { + BuddyPreferences.saveCoinsForWidget(context, 101, 2) + BuddyPreferences.saveCoinsForWidget(context, 102, 5) + + val intent = Intent(context, StoreActivity::class.java) + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + val texts = collectSemanticsTexts(activity) + assertTrue("Expected 'Widget ID: 101' in $texts", texts.contains("Widget ID: 101")) + assertTrue("Expected 'Coins: 2' in $texts", texts.contains("Coins: 2")) + assertTrue("Expected 'Widget ID: 102' in $texts", texts.contains("Widget ID: 102")) + assertTrue("Expected 'Coins: 5' in $texts", texts.contains("Coins: 5")) + } + } + } + + @Test + fun storeActivity_noActiveWidgets_displaysEmptyStateMessage() { + val intent = Intent(context, StoreActivity::class.java) + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + val texts = collectSemanticsTexts(activity) + assertTrue( + "Expected empty state message in $texts", + texts.contains("No active Android Pet widgets"), + ) + } + } + } + + @Test + fun onDeleted_removesDeletedWidgetStateOnly() { + BuddyPreferences.saveCoinsForWidget(context, 101, 4) + BuddyPreferences.saveTapsCount(context, 101, 2) + BuddyPreferences.saveCoinsForWidget(context, 102, 7) + + AndroidPetWidget().onDeleted(context, intArrayOf(101)) + + assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 101)) + assertEquals(0, BuddyPreferences.getTapsCount(context, 101)) + assertEquals(7, BuddyPreferences.getCoinsForWidget(context, 102)) + assertEquals(listOf(102), BuddyPreferences.getActiveWidgetIds(context)) + } + + @Test + fun multipleWidgets_actionClickedBroadcasts_operateIndependentlyWithoutInterference() = runBlocking { + val widgetA = 10 + val widgetB = 20 + + // Capture both widgets (simulating widget A and widget B both pinned) + captureWidgetDocument(widgetA) + captureWidgetDocument(widgetB) + + // Claim action for widget A (slot 0 -> id = 10 * 1000 + 0 = 10000) + // Store action for widget B (slot 1 -> id = 20 * 1000 + 1 = 20001) + val receiver = AndroidPetWidget() + val claimIntentA = Intent(context, AndroidPetWidget::class.java).apply { + action = "androidx.compose.remote.ACTION_CLICKED" + putExtra("widgetId", widgetA) + putExtra("id", widgetA * 1000 + 0) + } + receiver.onReceive(context, claimIntentA) + + assertEquals(1, BuddyPreferences.getCoinsForWidget(context, widgetA)) + assertEquals(0, BuddyPreferences.getCoinsForWidget(context, widgetB)) + + // Now click StoreButton on widget B via ACTION_CLICKED without manually re-updating widget B first + val storeIntentB = Intent(context, AndroidPetWidget::class.java).apply { + action = "androidx.compose.remote.ACTION_CLICKED" + putExtra("widgetId", widgetB) + putExtra("id", widgetB * 1000 + 1) + } + receiver.onReceive(context, storeIntentB) + + val startedIntent = shadowOf(context as android.app.Application).nextStartedActivity + assertNotNull("Expected StoreButton click on widget B to start StoreActivity", startedIntent) + assertEquals( + widgetB, + startedIntent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ), + ) + } + + @Test + fun assetsAndPreferences_edgeCases_decodeBitmapsAndClampNegativeValues() { + val claimBitmap = WidgetAssetProvider.claimCreditBitmap(context) + val storeBitmap = WidgetAssetProvider.storeBitmap(context) + assertTrue(claimBitmap.width > 0 && claimBitmap.height > 0) + assertTrue(storeBitmap.width > 0 && storeBitmap.height > 0) + + // Negative values clamp to 0 + BuddyPreferences.saveCoinsForWidget(context, 55, -10) + BuddyPreferences.saveTapsCount(context, 55, -5) + assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 55)) + assertEquals(0, BuddyPreferences.getTapsCount(context, 55)) + + // Invalid widget ID (0) is excluded from active widget IDs + BuddyPreferences.saveCoinsForWidget(context, AppWidgetManager.INVALID_APPWIDGET_ID, 3) + assertFalse( + BuddyPreferences.getActiveWidgetIds(context) + .contains(AppWidgetManager.INVALID_APPWIDGET_ID) + ) + } + + @Test + fun onUpdate_andUpdateAppWidget_populateLambdaActionsForAllWidgetsEvenWhenUnbound() { + val manager = AppWidgetManager.getInstance(context) + val widget = AndroidPetWidget() + + // Calling onUpdate with multiple unbound widget IDs in Robolectric must still + // compose Content and preserve registered WidgetLambdaActions for both widgets. + widget.onUpdate(context, manager, intArrayOf(10, 20)) + + RemoteWidgetActionBridge.runLambdaAction(10 * 1000 + 0) + assertEquals(1, BuddyPreferences.getCoinsForWidget(context, 10)) + assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 20)) + + RemoteWidgetActionBridge.runLambdaAction(20 * 1000 + 0) + assertEquals(1, BuddyPreferences.getCoinsForWidget(context, 10)) + assertEquals(1, BuddyPreferences.getCoinsForWidget(context, 20)) + + // updateAppWidget must also compose Content for a new widget ID and send + // ACTION_APPWIDGET_UPDATE guarded by EXTRA_ALREADY_UPDATED. + AndroidPetWidget.updateAppWidget(context, 30) + RemoteWidgetActionBridge.runLambdaAction(30 * 1000 + 0) + assertEquals(1, BuddyPreferences.getCoinsForWidget(context, 30)) + } + + @Test + fun directContentCapture_withoutExternalCompositionLocalProvider_registersCorrectWidgetId() = runBlocking { + val widgetId = 77 + val widget = AndroidPetWidget() + val captured = captureSingleRemoteDocument(context = context) { + widget.Content(context, widgetId) + } + val buffer = RemoteComposeBuffer.fromInputStream(ByteArrayInputStream(captured.bytes)) + val doc = CoreDocument().apply { + initFromBuffer(buffer) + } + val remoteContext = TestRemoteContext(widgetId) + doc.initializeContext(remoteContext) + doc.paint(remoteContext, Theme.UNSPECIFIED) + + // Click StoreButton center (264f, 264f) -> must trigger actionId 77001 (not -999) + doc.onClick(remoteContext, 264f, 264f) + assertEquals(listOf(widgetId * 1000 + 1), remoteContext.triggeredActionIds) + + val startedIntent = shadowOf(context as android.app.Application).nextStartedActivity + assertNotNull("Expected StoreButton click to start StoreActivity", startedIntent) + assertEquals( + widgetId, + startedIntent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ), + ) + } + + @Test + fun storeActivity_livePreferenceUpdates_recomposeCoinBalanceImmediately() { + BuddyPreferences.saveCoinsForWidget(context, 42, 1) + + val intent = Intent(context, StoreActivity::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 42) + } + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + val initialTexts = collectSemanticsTexts(activity) + assertTrue("Expected 'Coins: 1' in $initialTexts", initialTexts.contains("Coins: 1")) + + // Mutate preferences while StoreActivity is already resumed + BuddyPreferences.addCoinsForWidget(context, 42, 2) + val updatedTexts = collectSemanticsTexts(activity) + assertTrue("Expected 'Coins: 3' after live update in $updatedTexts", updatedTexts.contains("Coins: 3")) + } + } + } + + @Test + fun onUpdate_registersWidgetsWithZeroCoins_andStoreActivityDisplaysThem() { + val manager = AppWidgetManager.getInstance(context) + AndroidPetWidget().onUpdate(context, manager, intArrayOf(501, 502)) + + assertEquals(listOf(501, 502), BuddyPreferences.getActiveWidgetIds(context)) + assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 501)) + assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 502)) + + // Launching StoreActivity without EXTRA_APPWIDGET_ID (as from the samples list) + // must still display both active widgets with 0 coins. + ActivityScenario.launch(StoreActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val texts = collectSemanticsTexts(activity) + assertTrue("Expected 'Widget ID: 501' in $texts", texts.contains("Widget ID: 501")) + assertTrue("Expected 'Widget ID: 502' in $texts", texts.contains("Widget ID: 502")) + assertTrue("Expected 'Coins: 0' in $texts", texts.contains("Coins: 0")) + } + } + } + + @Test + fun buddyRepository_andTapsCountInteroperability_syncCoinsAndTapsCount() { + val repo = BuddyRepository(context) + val widgetId = 88 + + assertEquals(0, repo.getCoinsForWidget(widgetId)) + assertEquals(0, repo.getTapsCount(widgetId)) + + // playWithBuddy increments both coins and tapsCount (GamingWidget compatibility) + val updated = repo.playWithBuddy(widgetId) + assertEquals(1, updated) + assertEquals(1, repo.getCoinsForWidget(widgetId)) + assertEquals(1, repo.getTapsCount(widgetId)) + assertEquals(1, repo.getWidgetBuddyState(widgetId).coins) + assertEquals(1, repo.getWidgetBuddyState(widgetId).tapsCount) + + // Saving via tapsCount alone (GamingWidget convention) is reflected by getCoinsForWidget + val otherWidgetId = 89 + BuddyPreferences.addTapsCount(context, otherWidgetId, 4) + assertEquals(4, BuddyPreferences.getCoinsForWidget(context, otherWidgetId)) + assertEquals(4, BuddyPreferences.getWidgetBuddyState(context, otherWidgetId).coins) + } + + private fun collectSemanticsTexts(activity: Activity): List { + shadowOf(Looper.getMainLooper()).idle() + val rootView = activity.window.decorView + val rootForTest = findRootForTest(rootView) + assertNotNull("Expected AndroidComposeView (RootForTest) in activity view hierarchy", rootForTest) + val texts = mutableListOf() + collectNodeTexts(rootForTest!!.semanticsOwner.unmergedRootSemanticsNode, texts) + return texts + } + + private fun findRootForTest(view: View): RootForTest? { + if (view is RootForTest) return view + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + val found = findRootForTest(view.getChildAt(i)) + if (found != null) return found + } + } + return null + } + + private fun collectNodeTexts(node: SemanticsNode, out: MutableList) { + node.config.getOrNull(SemanticsProperties.Text)?.forEach { annotatedString -> + out.add(annotatedString.text) + } + node.children.forEach { child -> + collectNodeTexts(child, out) + } + } + + private suspend fun captureWidgetDocument(widgetId: Int): CoreDocument { + RemoteWidgetActionBridge.clearLambdaActions() + val widget = AndroidPetWidget() + val captured = captureSingleRemoteDocument(context = context) { + CompositionLocalProvider( + RemoteWidgetActionBridge.getLocalWidget() provides + RemoteWidgetActionBridge.createWidgetInformation(widgetId) + ) { + widget.Content(context, widgetId) + } + } + val buffer = RemoteComposeBuffer.fromInputStream(ByteArrayInputStream(captured.bytes)) + return CoreDocument().apply { + initFromBuffer(buffer) + } + } + + private fun findComponentByBounds( + component: Component, + x: Float, + y: Float, + w: Float, + h: Float, + ): Component? { + if ( + component is LayoutComponent && + component.x == x && + component.y == y && + component.width == w && + component.height == h + ) { + return component + } + for (op in component.list) { + if (op is Component) { + val found = findComponentByBounds(op, x, y, w, h) + if (found != null) return found + } + } + return null + } + + private fun describeTree(component: Component, indent: String = ""): String { + val sb = StringBuilder() + if (component is LayoutComponent) { + sb.appendLine("$indent${component.javaClass.simpleName}(id=${component.componentId}, x=${component.x}, y=${component.y}, w=${component.width}, h=${component.height}, vis=${component.isVisible})") + } else { + sb.appendLine("$indent${component.javaClass.simpleName}(id=${component.componentId}, vis=${component.isVisible})") + } + for (op in component.list) { + if (op is Component) { + sb.append(describeTree(op, "$indent ")) + } else { + sb.appendLine("$indent op: ${op.javaClass.simpleName} ($op)") + } + } + return sb.toString() + } + + private inner class TestRemoteContext(private val widgetId: Int) : RemoteContext() { + val triggeredActionIds = mutableListOf() + + init { + mWidth = 300f + mHeight = 300f + density = 1f + setAnimationEnabled(false) + mPaintContext = NoOpPaintContext(this) + } + + override fun loadPathData(instanceId: Int, winding: Int, floatPath: FloatArray) { + mRemoteComposeState.putPathData(instanceId, floatPath) + mRemoteComposeState.putPathWinding(instanceId, winding) + } + override fun getPathData(instanceId: Int): FloatArray? = + mRemoteComposeState.getPathData(instanceId) + override fun loadVariableName(varName: String, varId: Int, varType: Int) {} + override fun loadColor(id: Int, color: Int) { + mRemoteComposeState.updateColor(id, color) + } + override fun loadBitmap( + imageId: Int, + encoding: Short, + type: Short, + width: Int, + height: Int, + bitmap: ByteArray, + ) {} + override fun loadText(id: Int, text: String) { + if (!mRemoteComposeState.containsId(id)) { + mRemoteComposeState.cacheData(id, text) + } else { + mRemoteComposeState.updateData(id, text) + } + } + override fun getText(id: Int): String? = mRemoteComposeState.getFromId(id) as? String + override fun loadFloat(id: Int, value: Float) { + mRemoteComposeState.updateFloat(id, value) + } + override fun overrideFloat(id: Int, value: Float) { + mRemoteComposeState.overrideFloat(id, value) + } + override fun loadInteger(id: Int, value: Int) { + mRemoteComposeState.updateInteger(id, value) + } + override fun overrideInteger(id: Int, value: Int) { + mRemoteComposeState.overrideInteger(id, value) + } + override fun overrideText(id: Int, valueId: Int) { + val text = getText(valueId) ?: "" + mRemoteComposeState.overrideData(id, text) + } + override fun loadAnimatedFloat( + id: Int, + animatedFloat: androidx.compose.remote.core.operations.FloatExpression, + ) {} + override fun loadShader(id: Int, value: androidx.compose.remote.core.operations.ShaderData) {} + override fun getFloat(id: Int): Float = mRemoteComposeState.getFloat(id) + override fun putObject(id: Int, value: Any) { + mRemoteComposeState.updateObject(id, value) + } + override fun getObject(id: Int): Any? = mRemoteComposeState.getObject(id) + override fun getInteger(id: Int): Int = mRemoteComposeState.getInteger(id) + override fun getLong(id: Int): Long = 0L + override fun getColor(id: Int): Int = mRemoteComposeState.getColor(id) + override fun listensTo(id: Int, variableSupport: VariableSupport) { + mRemoteComposeState.listenToVar(id, variableSupport) + } + override fun updateOps(): Int = + mRemoteComposeState.getOpsToUpdate(this, System.currentTimeMillis()) + override fun getShader(id: Int): androidx.compose.remote.core.operations.ShaderData? = null + override fun addClickArea( + id: Int, + contentDescription: Int, + left: Float, + top: Float, + right: Float, + bottom: Float, + metadataId: Int, + ) {} + override fun runAction(id: Int, metadata: String) { + triggeredActionIds.add(id) + RemoteWidgetActionBridge.runLambdaAction(id) + } + override fun runNamedAction(id: Int, value: Any?) { + triggeredActionIds.add(id) + RemoteWidgetActionBridge.runLambdaAction(id) + } + override fun hapticEffect(type: Int) {} + override fun addCollection( + id: Int, + collection: androidx.compose.remote.core.operations.utilities.ArrayAccess, + ) {} + override fun putDataMap( + id: Int, + map: androidx.compose.remote.core.operations.utilities.DataMap, + ) {} + override fun getDataMap(id: Int): androidx.compose.remote.core.operations.utilities.DataMap? = null + override fun setNamedColorOverride(name: String, value: Int) {} + override fun setNamedStringOverride(name: String, value: String) {} + override fun clearNamedStringOverride(name: String) {} + override fun setNamedBooleanOverride(name: String, value: Boolean) {} + override fun clearNamedBooleanOverride(name: String) {} + override fun setNamedIntegerOverride(name: String, value: Int) {} + override fun clearNamedIntegerOverride(name: String) {} + override fun setNamedFloatOverride(name: String, value: Float) {} + override fun clearNamedFloatOverride(name: String) {} + override fun setNamedLong(name: String, value: Long) {} + override fun setNamedDataOverride(name: String, value: Any) {} + override fun clearNamedDataOverride(name: String) {} + } + + private class NoOpPaintContext(context: RemoteContext) : PaintContext(context) { + override fun roundedClipRect( + width: Float, + height: Float, + topStart: Float, + topEnd: Float, + bottomStart: Float, + bottomEnd: Float, + ) {} + override fun matrixFromPath(pathId: Int, fraction: Float, vOffset: Float, flags: Int) {} + override fun drawToBitmap(bitmapId: Int, mode: Int, color: Int) {} + override fun drawBitmap( + imageId: Int, + srcLeft: Int, + srcTop: Int, + srcRight: Int, + srcBottom: Int, + dstLeft: Int, + dstTop: Int, + dstRight: Int, + dstBottom: Int, + cdId: Int, + ) {} + override fun scale(scaleX: Float, scaleY: Float) {} + override fun translate(translateX: Float, translateY: Float) {} + override fun drawArc( + left: Float, + top: Float, + right: Float, + bottom: Float, + startAngle: Float, + sweepAngle: Float, + ) {} + override fun drawSector( + left: Float, + top: Float, + right: Float, + bottom: Float, + startAngle: Float, + sweepAngle: Float, + ) {} + override fun drawBitmap(id: Int, left: Float, top: Float, right: Float, bottom: Float) {} + override fun drawCircle(centerX: Float, centerY: Float, radius: Float) {} + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) {} + override fun drawOval(left: Float, top: Float, right: Float, bottom: Float) {} + override fun drawPath(id: Int, start: Float, end: Float) {} + override fun drawRect(left: Float, top: Float, right: Float, bottom: Float) {} + override fun savePaint() {} + override fun restorePaint() {} + override fun replacePaint(paint: PaintBundle) {} + override fun drawRoundRect( + left: Float, + top: Float, + right: Float, + bottom: Float, + radiusX: Float, + radiusY: Float, + ) {} + override fun drawTextOnPath(textId: Int, pathId: Int, hOffset: Float, vOffset: Float) {} + override fun getTextBounds( + textId: Int, + start: Int, + end: Int, + flags: Int, + bounds: FloatArray, + ) { + bounds[0] = 0f + bounds[1] = 0f + bounds[2] = 50f + bounds[3] = 20f + } + override fun layoutComplexText( + textId: Int, + start: Int, + end: Int, + alignment: Int, + overflow: Int, + maxLines: Int, + maxWidth: Float, + maxHeight: Float, + letterSpacing: Float, + lineHeightAdd: Float, + lineHeightMultiplier: Float, + lineBreakStrategy: Int, + hyphenationFrequency: Int, + justificationMode: Int, + underline: Boolean, + strikethrough: Boolean, + flags: Int, + ): RcPlatformServices.ComputedTextLayout? = null + override fun drawComplexText(complexTextLayout: RcPlatformServices.ComputedTextLayout?) {} + override fun drawTextRun( + textId: Int, + start: Int, + end: Int, + contextStart: Int, + contextEnd: Int, + x: Float, + y: Float, + rtl: Boolean, + ) {} + override fun drawTweenPath( + path1Id: Int, + path2Id: Int, + tween: Float, + start: Float, + stop: Float, + ) {} + override fun tweenPath(out: Int, path1: Int, path2: Int, tween: Float) {} + override fun combinePath(out: Int, path1: Int, path2: Int, operation: Byte) {} + override fun applyPaint(paintData: PaintBundle) {} + override fun matrixScale(scaleX: Float, scaleY: Float, centerX: Float, centerY: Float) {} + override fun matrixTranslate(translateX: Float, translateY: Float) {} + override fun matrixSkew(skewX: Float, skewY: Float) {} + override fun matrixRotate(rotate: Float, pivotX: Float, pivotY: Float) {} + override fun matrixSave() {} + override fun matrixRestore() {} + override fun clipRect(left: Float, top: Float, right: Float, bottom: Float) {} + override fun clipPath(pathId: Int, regionOp: Int) {} + override fun reset() {} + override fun startGraphicsLayer(w: Int, h: Int) {} + override fun setGraphicsLayer(attributes: HashMap) {} + override fun endGraphicsLayer() {} + override fun getText(textId: Int): String? = mContext.getText(textId) + override fun log(content: String) {} + } +} From b16e84397ac31fa0d7cca1a52f599415fa77663d Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Mon, 12 Oct 2026 22:59:44 +0000 Subject: [PATCH 02/21] Make StoreActivity accessible only from within Android Pet widget --- .../com/example/platform/app/SampleDemo.kt | 10 ----- .../platform/ui/appwidgets/AppWidgets.kt | 38 ------------------- .../src/main/res/values/strings.xml | 1 - .../remote_compose/AndroidPetWidgetTest.kt | 3 +- 4 files changed, 1 insertion(+), 51 deletions(-) diff --git a/app/src/main/java/com/example/platform/app/SampleDemo.kt b/app/src/main/java/com/example/platform/app/SampleDemo.kt index 747f73dd..a2757752 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -69,7 +69,6 @@ import com.example.platform.storage.mediastore.SelectedPhotosAccessSample import com.example.platform.storage.photopicker.PhotoPickerSample import com.example.platform.storage.storageaccessframework.GetContentSample import com.example.platform.ui.appwidgets.AppWidgets -import com.example.platform.ui.appwidgets.remote_compose.StoreActivity import com.example.platform.ui.constraintlayout.AdvancedArrangementFragment import com.example.platform.ui.constraintlayout.AdvancedChainsFragment import com.example.platform.ui.constraintlayout.AspectRatioFragment @@ -652,15 +651,6 @@ val SAMPLE_DEMOS by lazy { } }, ), - ActivitySampleDemo( - id = "android-pet-store", - name = "Android Pet Store", - description = "Companion store for the Remote Compose Android Pet widget displaying active widget IDs and collected coin balances.", - documentation = "https://developer.android.com/develop/ui/views/appwidgets/overview", - apiSurface = UserInterfaceAppWidgetsApiSurface, - tags = listOf("App Widgets", "Remote Compose"), - content = StoreActivity::class.java, - ), ComposableSampleDemo( id = "constraintlayout-centering-views", name = "ConstraintLayout - 1. Centering Views", diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/AppWidgets.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/AppWidgets.kt index dcfede1d..a8d833a0 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/AppWidgets.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/AppWidgets.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.example.platform.ui.appwidgets.glance.layout.CanonicalLayoutActivity -import com.example.platform.ui.appwidgets.remote_compose.StoreActivity @RequiresApi(Build.VERSION_CODES.O) @Composable @@ -85,10 +84,6 @@ fun AppWidgetsList(widgetProviders: List) { CanonicalLayoutsInfoCard() } - item { - AndroidPetStoreInfoCard() - } - // If the launcher does not support pinning request show a banner if (!widgetManager.isRequestPinAppWidgetSupported) { item { @@ -134,39 +129,6 @@ private fun CanonicalLayoutsInfoCard() { } } -@Composable -private fun AndroidPetStoreInfoCard() { - val context = LocalContext.current - Card( - modifier = Modifier - .fillMaxWidth(), - onClick = { StoreActivity.start(context) }, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.padding(end = 8.dp)) { - Text( - text = stringResource(R.string.android_pet_store_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = stringResource(R.string.android_pet_store_description), - style = MaterialTheme.typography.bodyMedium, - ) - } - Image( - painter = painterResource(id = R.drawable.shop_button), - contentDescription = stringResource(R.string.android_pet_store_title), - ) - } - } -} - private fun MutableList.removeCanonicalLayouts(): List { return this.toMutableList() .filter { !it.provider.className.startsWith("com.example.platform.ui.appwidgets.glance.layout") } diff --git a/samples/user-interface/appwidgets/src/main/res/values/strings.xml b/samples/user-interface/appwidgets/src/main/res/values/strings.xml index 2b97d9e8..44a02b80 100644 --- a/samples/user-interface/appwidgets/src/main/res/values/strings.xml +++ b/samples/user-interface/appwidgets/src/main/res/values/strings.xml @@ -31,7 +31,6 @@ An animated interactive pet widget powered by Remote Compose. Remote Compose widgets work on Android 17 and higher devices. Use an Android 17 device. Android Pet Store - View active Android Pet widgets and their collected coins. LayoutSamples Settings diff --git a/samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt b/samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt index 7b5571d8..5a08e3f8 100644 --- a/samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt +++ b/samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt @@ -400,8 +400,7 @@ class AndroidPetWidgetTest { assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 501)) assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 502)) - // Launching StoreActivity without EXTRA_APPWIDGET_ID (as from the samples list) - // must still display both active widgets with 0 coins. + // Launching StoreActivity must display both active widgets with 0 coins. ActivityScenario.launch(StoreActivity::class.java).use { scenario -> scenario.onActivity { activity -> val texts = collectSemanticsTexts(activity) From 71268ce63bf38b7d9ccf7210578099947da30eec Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Mon, 12 Oct 2026 23:20:23 +0000 Subject: [PATCH 03/21] Remove unit tests and test-only bridge from Android Pet widget --- .../appwidgets/build.gradle.kts | 24 - .../remote_compose/AndroidPetWidget.kt | 104 +-- .../remote_compose/ClaimCreditButton.kt | 83 +- .../RemoteWidgetActionBridge.java | 69 -- .../remote_compose/StoreActivity.kt | 5 +- .../appwidgets/remote_compose/StoreButton.kt | 80 +- .../remote_compose/WidgetActionBar.kt | 6 - .../remote_compose/WidgetAssetProvider.kt | 4 - .../remote_compose/AndroidPetWidgetTest.kt | 765 ------------------ 9 files changed, 82 insertions(+), 1058 deletions(-) delete mode 100644 samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/RemoteWidgetActionBridge.java delete mode 100644 samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt diff --git a/samples/user-interface/appwidgets/build.gradle.kts b/samples/user-interface/appwidgets/build.gradle.kts index 9125a5aa..8df4100a 100644 --- a/samples/user-interface/appwidgets/build.gradle.kts +++ b/samples/user-interface/appwidgets/build.gradle.kts @@ -42,26 +42,6 @@ android { } testOptions { targetSdk = 35 - unitTests { - isIncludeAndroidResources = true - all { - it.systemProperty("robolectric.conscryptMode", "OFF") - val robolectricM2Dir = file( - "${System.getProperty("user.home")}/.m2/repository/org/robolectric/android-all-instrumented/15-robolectric-12650502-i7" - ) - if (robolectricM2Dir.exists()) { - it.systemProperty("robolectric.offline", "true") - it.systemProperty("robolectric.dependency.dir", robolectricM2Dir.absolutePath) - } - val asmCacheDir = file("${System.getProperty("user.home")}/.gradle/caches/modules-2/files-2.1/org.ow2.asm") - if (asmCacheDir.exists()) { - val asm99Jars = fileTree(asmCacheDir) { - include("**/9.9/**/*.jar") - } - it.classpath = asm99Jars + it.classpath - } - } - } } } @@ -93,8 +73,4 @@ dependencies { debugImplementation(libs.androidx.glance.preview) debugImplementation(libs.androidx.glance.appwidget.preview) - - testImplementation(libs.junit4) - testImplementation("androidx.test:core-ktx:1.6.1") - testImplementation("org.robolectric:robolectric:4.14") } diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt index b48d839e..4b0a9be7 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt @@ -20,10 +20,6 @@ import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.os.Build -import androidx.annotation.DrawableRes -import androidx.annotation.RequiresApi import androidx.compose.remote.creation.compose.action.valueChange import androidx.compose.remote.creation.compose.layout.RemoteAlignment import androidx.compose.remote.creation.compose.layout.RemoteBox @@ -42,7 +38,6 @@ import androidx.compose.remote.creation.compose.state.rs import androidx.compose.remote.creation.compose.state.selectIfGe import androidx.compose.remote.creation.compose.widgets.RemoteComposeWidget import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color @@ -56,12 +51,7 @@ class AndroidPetWidget : RemoteComposeWidget() { ) { return } - val preservedActions = RemoteWidgetActionBridge.snapshotLambdaActions() - runCatching { - super.onReceive(context, intent) - } - preservedActions.putAll(RemoteWidgetActionBridge.snapshotLambdaActions()) - RemoteWidgetActionBridge.restoreLambdaActions(preservedActions) + super.onReceive(context, intent) } override fun onUpdate( @@ -69,17 +59,12 @@ class AndroidPetWidget : RemoteComposeWidget() { appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { - val preservedActions = RemoteWidgetActionBridge.snapshotLambdaActions() for (id in appWidgetIds) { if (id != AppWidgetManager.INVALID_APPWIDGET_ID) { BuddyPreferences.ensureWidgetRegistered(context, id) } - runCatching { - super.onUpdate(context, appWidgetManager, intArrayOf(id)) - } - preservedActions.putAll(RemoteWidgetActionBridge.snapshotLambdaActions()) } - RemoteWidgetActionBridge.restoreLambdaActions(preservedActions) + super.onUpdate(context, appWidgetManager, appWidgetIds) } override fun onDeleted(context: Context, appWidgetIds: IntArray) { @@ -93,7 +78,6 @@ class AndroidPetWidget : RemoteComposeWidget() { @RemoteComposable @Composable override fun Content(context: Context, widgetId: Int) { - RemoteWidgetActionBridge.resetLambdaCounter() val appContext = context.applicationContext ?: context val claimCreditIconBitmap = remember(appContext) { WidgetAssetProvider.claimCreditBitmap(appContext) @@ -103,60 +87,51 @@ class AndroidPetWidget : RemoteComposeWidget() { } val coins = BuddyPreferences.getCoinsForWidget(appContext, widgetId) - val zeroTaps = 0.ri val petTaps = rememberMutableRemoteInt(0) val nextPetTaps = petTaps + 1.ri val isClaimVisible = selectIfGe(petTaps, REQUIRED_TAPS_FOR_COIN.ri, 1.ri, 0.ri) - CompositionLocalProvider( - RemoteWidgetActionBridge.getLocalWidget() provides - RemoteWidgetActionBridge.createWidgetInformation(widgetId) + RemoteBox( + modifier = RemoteModifier.fillMaxSize().background(Color.White.rc), + contentAlignment = RemoteAlignment.Center ) { + // Materialize RemoteInt expression IDs in the active creation state before + // action modifiers serialize them into nested action lists. + RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { + petTaps.getIdForCreationState(remoteComposeCreationState) + nextPetTaps.getIdForCreationState(remoteComposeCreationState) + isClaimVisible.getIdForCreationState(remoteComposeCreationState) + } + + // Pet interactive touch target (placed below WidgetActionBar so button clicks + // on the action bar are not intercepted by the pet touch target). RemoteBox( - modifier = RemoteModifier.fillMaxSize().background(Color.White.rc), + modifier = RemoteModifier + .fillMaxSize() + .onTouchDown(valueChange(petTaps, nextPetTaps)), contentAlignment = RemoteAlignment.Center ) { - // Materialize RemoteInt expression IDs in the active creation state before - // action modifiers serialize them into nested action lists. - RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { - zeroTaps.getIdForCreationState(remoteComposeCreationState) - petTaps.getIdForCreationState(remoteComposeCreationState) - nextPetTaps.getIdForCreationState(remoteComposeCreationState) - isClaimVisible.getIdForCreationState(remoteComposeCreationState) - } - - // Pet interactive touch target (placed below WidgetActionBar so button clicks - // on the action bar are not intercepted by the pet touch target). - RemoteBox( - modifier = RemoteModifier - .fillMaxSize() - .onTouchDown(valueChange(petTaps, nextPetTaps)), - contentAlignment = RemoteAlignment.Center + RemoteColumn( + horizontalAlignment = RemoteAlignment.CenterHorizontally ) { - RemoteColumn( - horizontalAlignment = RemoteAlignment.CenterHorizontally - ) { - RemoteText( - text = "Android Pet".rs, - color = Color.Black.rc - ) - RemoteText( - text = "Coins: $coins".rs, - color = Color.DarkGray.rc - ) - } + RemoteText( + text = "Android Pet".rs, + color = Color.Black.rc + ) + RemoteText( + text = "Coins: $coins".rs, + color = Color.DarkGray.rc + ) } - - WidgetActionBar( - context = appContext, - widgetId = widgetId, - claimCreditIconBitmap = claimCreditIconBitmap, - storeIconBitmap = storeIconBitmap, - isClaimVisible = isClaimVisible, - petTaps = petTaps, - resetValue = zeroTaps, - ) } + + WidgetActionBar( + context = appContext, + widgetId = widgetId, + claimCreditIconBitmap = claimCreditIconBitmap, + storeIconBitmap = storeIconBitmap, + isClaimVisible = isClaimVisible, + ) } } @@ -165,9 +140,6 @@ class AndroidPetWidget : RemoteComposeWidget() { private const val EXTRA_ALREADY_UPDATED = "com.example.platform.ui.appwidgets.remote_compose.EXTRA_ALREADY_UPDATED" - fun decodeBitmapFromResource(context: Context, @DrawableRes resId: Int): Bitmap = - WidgetAssetProvider.decodeBitmap(context, resId) - fun updateAppWidget( context: Context, appWidgetManager: AppWidgetManager, @@ -188,7 +160,3 @@ class AndroidPetWidget : RemoteComposeWidget() { } } } - -fun refreshAndroidPetWidget(context: Context, widgetId: Int) { - AndroidPetWidget.updateAppWidget(context, widgetId) -} diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt index 4f934661..fdb40faf 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt @@ -19,26 +19,20 @@ package com.example.platform.ui.appwidgets.remote_compose import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap -import androidx.compose.remote.creation.compose.action.combinedAction -import androidx.compose.remote.creation.compose.action.valueChange import androidx.compose.remote.creation.compose.layout.RemoteAlignment import androidx.compose.remote.creation.compose.layout.RemoteBox import androidx.compose.remote.creation.compose.layout.RemoteCanvas import androidx.compose.remote.creation.compose.layout.RemoteComposable import androidx.compose.remote.creation.compose.modifier.RemoteModifier -import androidx.compose.remote.creation.compose.modifier.clickable import androidx.compose.remote.creation.compose.modifier.fillMaxSize import androidx.compose.remote.creation.compose.modifier.padding import androidx.compose.remote.creation.compose.modifier.size import androidx.compose.remote.creation.compose.modifier.visibility -import androidx.compose.remote.creation.compose.state.MutableRemoteInt import androidx.compose.remote.creation.compose.state.RemoteInt import androidx.compose.remote.creation.compose.state.rb import androidx.compose.remote.creation.compose.state.rdp -import androidx.compose.remote.creation.compose.state.ri import androidx.compose.remote.creation.compose.widgets.onClick import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.graphics.asImageBitmap @@ -50,71 +44,38 @@ fun ClaimCreditButton( widgetId: Int, claimCreditIconBitmap: Bitmap? = null, isClaimVisible: RemoteInt? = null, - petTaps: MutableRemoteInt? = null, - resetValue: RemoteInt = 0.ri, ) { val bitmap = claimCreditIconBitmap ?: remember { WidgetAssetProvider.claimCreditBitmap(context) } - val claimAction: () -> Unit = { - val updated = BuddyPreferences.addCoinsForWidget(context, widgetId, 1) - BuddyPreferences.saveTapsCount(context, widgetId, updated) - } - - CompositionLocalProvider( - RemoteWidgetActionBridge.getLocalWidget() provides - RemoteWidgetActionBridge.createWidgetInformation(widgetId) + RemoteBox( + contentAlignment = RemoteAlignment.BottomEnd, + modifier = RemoteModifier.fillMaxSize(), ) { + val buttonModifier = if (isClaimVisible != null) { + RemoteModifier + .padding(end = 68.rdp, bottom = 12.rdp) + .size(48.rdp) + .visibility(isClaimVisible) + } else { + RemoteModifier + .padding(end = 68.rdp, bottom = 12.rdp) + .size(48.rdp) + } + RemoteBox( - contentAlignment = RemoteAlignment.BottomEnd, - modifier = RemoteModifier.fillMaxSize(), + modifier = buttonModifier + .onClick { + BuddyRepository(context).playWithBuddy(widgetId) + }, ) { - val baseModifier = if (isClaimVisible != null) { - RemoteModifier - .padding(end = 68.rdp, bottom = 12.rdp) - .size(48.rdp) - .visibility(isClaimVisible) - } else { - RemoteModifier - .padding(end = 68.rdp, bottom = 12.rdp) - .size(48.rdp) - } - - val buttonModifier = if (petTaps != null) { - baseModifier.clickable( - combinedAction( - valueChange(petTaps, resetValue), - RemoteWidgetActionBridge.createLambdaAction(widgetId, claimAction), - ) + RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { + drawScaledBitmap( + image = bitmap.asImageBitmap().rb, + contentDescription = "Claim Credit", ) - } else { - baseModifier.onClick(claimAction) - } - - RemoteBox(modifier = buttonModifier) { - RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { - drawScaledBitmap( - image = bitmap.asImageBitmap().rb, - contentDescription = "Claim Credit", - ) - } } } } } - -@SuppressLint("RestrictedApiAndroidX", "RestrictedApi") -@RemoteComposable -@Composable -fun ClaimCreditButton( - context: Context, - widgetId: Int, - isClaimVisible: RemoteInt, - claimBitmap: Bitmap? = null, -): Unit = ClaimCreditButton( - context = context, - widgetId = widgetId, - claimCreditIconBitmap = claimBitmap, - isClaimVisible = isClaimVisible, -) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/RemoteWidgetActionBridge.java b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/RemoteWidgetActionBridge.java deleted file mode 100644 index 0b5a6ee0..00000000 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/RemoteWidgetActionBridge.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2026 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.platform.ui.appwidgets.remote_compose; - -import android.annotation.SuppressLint; -import androidx.compose.remote.creation.compose.action.Action; -import androidx.compose.remote.creation.compose.widgets.WidgetInformation; -import androidx.compose.remote.creation.compose.widgets.WidgetInformationKt; -import androidx.compose.remote.creation.compose.widgets.WidgetLambdaAction; -import androidx.compose.runtime.ProvidableCompositionLocal; -import kotlin.Unit; -import kotlin.jvm.functions.Function0; - -/** - * Bridge for combining a host {@link WidgetLambdaAction} with local in-document Remote Compose - * state actions inside a single click modifier. - */ -@SuppressLint({"RestrictedApiAndroidX", "RestrictedApi"}) -public final class RemoteWidgetActionBridge { - private RemoteWidgetActionBridge() {} - - public static Action createLambdaAction(int widgetId, Function0 action) { - return new WidgetLambdaAction(widgetId, action); - } - - public static ProvidableCompositionLocal getLocalWidget() { - return WidgetInformationKt.getLocalWidget(); - } - - public static WidgetInformation createWidgetInformation(int widgetId) { - return new WidgetInformation(widgetId); - } - - public static void clearLambdaActions() { - WidgetLambdaAction.Companion.clear(); - } - - public static void resetLambdaCounter() { - WidgetLambdaAction.Companion.setCounter(0); - } - - public static java.util.Map snapshotLambdaActions() { - return new java.util.HashMap<>(WidgetLambdaAction.Companion.getMap()); - } - - public static void restoreLambdaActions(java.util.Map snapshot) { - if (snapshot != null) { - WidgetLambdaAction.Companion.getMap().putAll(snapshot); - } - } - - public static void runLambdaAction(int actionId) { - WidgetLambdaAction.Companion.run(actionId); - } -} diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt index fdc618d4..9ae7611a 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -52,7 +52,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -225,9 +224,7 @@ private fun WidgetCoinCard( MaterialTheme.colorScheme.surfaceVariant } Card( - modifier = Modifier - .fillMaxWidth() - .testTag("widget_card_${state.widgetId}"), + modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = containerColor), ) { Row( diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt index d26198de..92719e72 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt @@ -37,7 +37,6 @@ import androidx.compose.remote.creation.compose.state.rb import androidx.compose.remote.creation.compose.state.rdp import androidx.compose.remote.creation.compose.widgets.onClick import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.graphics.asImageBitmap @@ -54,70 +53,37 @@ fun StoreButton( WidgetAssetProvider.storeBitmap(context) } - CompositionLocalProvider( - RemoteWidgetActionBridge.getLocalWidget() provides - RemoteWidgetActionBridge.createWidgetInformation(widgetId) + RemoteBox( + contentAlignment = RemoteAlignment.BottomEnd, + modifier = RemoteModifier.fillMaxSize(), ) { + val buttonModifier = if (isStoreVisible != null) { + RemoteModifier + .padding(12.rdp) + .size(48.rdp) + .visibility(isStoreVisible) + } else { + RemoteModifier + .padding(12.rdp) + .size(48.rdp) + } + RemoteBox( - contentAlignment = RemoteAlignment.BottomEnd, - modifier = RemoteModifier.fillMaxSize(), + modifier = buttonModifier + .onClick { + launchStoreActivity(context, widgetId) + }, ) { - val buttonModifier = if (isStoreVisible != null) { - RemoteModifier - .padding(12.rdp) - .size(48.rdp) - .visibility(isStoreVisible) - } else { - RemoteModifier - .padding(12.rdp) - .size(48.rdp) - } - - RemoteBox( - modifier = buttonModifier - .onClick { - launchStoreActivity(context, widgetId) - }, - ) { - RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { - drawScaledBitmap( - image = bitmap.asImageBitmap().rb, - contentDescription = "Store", - ) - } + RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { + drawScaledBitmap( + image = bitmap.asImageBitmap().rb, + contentDescription = "Store", + ) } } } } -@SuppressLint("RestrictedApiAndroidX", "RestrictedApi") -@RemoteComposable -@Composable -fun StoreButton( - isStoreVisible: RemoteInt, - context: Context, - widgetId: Int, -): Unit = StoreButton( - context = context, - widgetId = widgetId, - isStoreVisible = isStoreVisible, -) - -@SuppressLint("RestrictedApiAndroidX", "RestrictedApi") -@RemoteComposable -@Composable -fun StoreButton( - context: Context, - widgetId: Int, - isStoreVisible: RemoteInt, - iconBitmap: Bitmap? = null, -): Unit = StoreButton( - context = context, - widgetId = widgetId, - storeIconBitmap = iconBitmap, - isStoreVisible = isStoreVisible, -) - internal fun launchStoreActivity(context: Context, widgetId: Int) { val intent = Intent(context, StoreActivity::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt index 627ea342..0dcb573c 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt @@ -20,9 +20,7 @@ import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import androidx.compose.remote.creation.compose.layout.RemoteComposable -import androidx.compose.remote.creation.compose.state.MutableRemoteInt import androidx.compose.remote.creation.compose.state.RemoteInt -import androidx.compose.remote.creation.compose.state.ri import androidx.compose.runtime.Composable @SuppressLint("RestrictedApiAndroidX", "RestrictedApi") @@ -34,16 +32,12 @@ fun WidgetActionBar( claimCreditIconBitmap: Bitmap? = null, storeIconBitmap: Bitmap? = null, isClaimVisible: RemoteInt? = null, - petTaps: MutableRemoteInt? = null, - resetValue: RemoteInt = 0.ri, ) { ClaimCreditButton( context = context, widgetId = widgetId, claimCreditIconBitmap = claimCreditIconBitmap, isClaimVisible = isClaimVisible, - petTaps = petTaps, - resetValue = resetValue, ) StoreButton( context = context, diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt index c8afd381..54753b5a 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt @@ -43,10 +43,6 @@ object WidgetAssetProvider { } } - fun clearCache() { - bitmapCache.clear() - } - private fun decodeDrawableToBitmap(context: Context, @DrawableRes resId: Int): Bitmap { val drawable = ContextCompat.getDrawable(context, resId) ?: return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) diff --git a/samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt b/samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt deleted file mode 100644 index 5a08e3f8..00000000 --- a/samples/user-interface/appwidgets/src/test/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidgetTest.kt +++ /dev/null @@ -1,765 +0,0 @@ -/* - * Copyright 2026 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.platform.ui.appwidgets.remote_compose - -import android.app.Activity -import android.appwidget.AppWidgetManager -import android.content.Context -import android.content.Intent -import android.os.Looper -import android.view.View -import android.view.ViewGroup -import androidx.compose.remote.core.CoreDocument -import androidx.compose.remote.core.PaintContext -import androidx.compose.remote.core.RcPlatformServices -import androidx.compose.remote.core.RemoteComposeBuffer -import androidx.compose.remote.core.RemoteContext -import androidx.compose.remote.core.VariableSupport -import androidx.compose.remote.core.operations.Theme -import androidx.compose.remote.core.operations.layout.Component -import androidx.compose.remote.core.operations.layout.LayoutComponent -import androidx.compose.remote.core.operations.paint.PaintBundle -import androidx.compose.remote.creation.compose.capture.captureSingleRemoteDocument -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.node.RootForTest -import androidx.compose.ui.semantics.SemanticsNode -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.semantics.getOrNull -import androidx.test.core.app.ActivityScenario -import androidx.test.core.app.ApplicationProvider -import java.io.ByteArrayInputStream -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config -import org.robolectric.annotation.ConscryptMode - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35]) -@ConscryptMode(ConscryptMode.Mode.OFF) -class AndroidPetWidgetTest { - - private lateinit var context: Context - - @Before - fun setUp() { - context = ApplicationProvider.getApplicationContext() - BuddyPreferences.prefs(context).edit().clear().commit() - WidgetAssetProvider.clearCache() - RemoteWidgetActionBridge.clearLambdaActions() - } - - @After - fun tearDown() { - BuddyPreferences.prefs(context).edit().clear().commit() - WidgetAssetProvider.clearCache() - } - - @Test - fun petTaps_coinButtonHiddenUntilThreeTaps_andResetsWhenClaimed() = runBlocking { - val widgetId = 42 - val doc = captureWidgetDocument(widgetId) - val remoteContext = TestRemoteContext(widgetId) - - doc.initializeContext(remoteContext) - doc.paint(remoteContext, Theme.UNSPECIFIED) - - val root = doc.rootLayoutComponent - assertNotNull("Expected RootLayoutComponent in CoreDocument", root) - - // StoreButton is 48x48 with 12dp padding at BottomEnd (outer box: x=228, y=228, w=72, h=72) - val storeBox = findComponentByBounds(root!!, x = 228f, y = 228f, w = 72f, h = 72f) - assertNotNull("Expected StoreButton at BottomEnd (228, 228, 72x72) in:\n${describeTree(root)}", storeBox) - assertTrue("StoreButton should always be visible", storeBox!!.isVisible) - - // ClaimCreditButton is 48x48 with end=68dp, bottom=12dp at BottomEnd (outer box: x=184, y=240, w=116, h=60) - val claimBox = findComponentByBounds(root, x = 184f, y = 240f, w = 116f, h = 60f) - assertNotNull("Expected ClaimCreditButton at BottomEnd (184, 240, 116x60) in:\n${describeTree(root)}", claimBox) - - // 0 taps: coin button is hidden - assertFalse("Coin button must be invisible at 0 taps", claimBox!!.isVisible) - - // Clicking coin button while invisible does nothing - doc.onClick(remoteContext, 208f, 264f) - assertEquals(0, BuddyPreferences.getCoinsForWidget(context, widgetId)) - - // Tap 1 on pet: coin button still invisible - doc.touchDown(remoteContext, 150f, 150f) - doc.paint(remoteContext, Theme.UNSPECIFIED) - doc.paint(remoteContext, Theme.UNSPECIFIED) - assertFalse("Coin button must remain invisible after 1 tap", claimBox.isVisible) - - // Tap 2 on pet: coin button still invisible - doc.touchDown(remoteContext, 150f, 150f) - doc.paint(remoteContext, Theme.UNSPECIFIED) - doc.paint(remoteContext, Theme.UNSPECIFIED) - assertFalse("Coin button must remain invisible after 2 taps", claimBox.isVisible) - - // Tap 3 on pet: coin button becomes visible locally without waking app process - doc.touchDown(remoteContext, 150f, 150f) - doc.paint(remoteContext, Theme.UNSPECIFIED) - doc.paint(remoteContext, Theme.UNSPECIFIED) - assertTrue( - "Coin button must become visible on the 3rd tap", - claimBox.isVisible, - ) - assertEquals( - "No host actions should fire during local pet taps", - 0, - remoteContext.triggeredActionIds.size, - ) - - // Tap the coin button to claim credit: - // 1. Persists +1 coin in BuddyPreferences - // 2. Resets local tap state so coin disappears again until another 3 taps - doc.onClick(remoteContext, 208f, 264f) - shadowOf(Looper.getMainLooper()).idle() - doc.paint(remoteContext, Theme.UNSPECIFIED) - doc.paint(remoteContext, Theme.UNSPECIFIED) - - assertEquals(1, BuddyPreferences.getCoinsForWidget(context, widgetId)) - assertFalse("Coin button must hide again after claiming", claimBox.isVisible) - - // Another 3 taps makes the coin button reappear and allows claiming a 2nd coin - repeat(2) { - doc.touchDown(remoteContext, 150f, 150f) - doc.paint(remoteContext, Theme.UNSPECIFIED) - doc.paint(remoteContext, Theme.UNSPECIFIED) - assertFalse(claimBox.isVisible) - } - doc.touchDown(remoteContext, 150f, 150f) - doc.paint(remoteContext, Theme.UNSPECIFIED) - doc.paint(remoteContext, Theme.UNSPECIFIED) - assertTrue("Coin button must reappear after another 3 taps", claimBox.isVisible) - - doc.onClick(remoteContext, 208f, 264f) - shadowOf(Looper.getMainLooper()).idle() - doc.paint(remoteContext, Theme.UNSPECIFIED) - doc.paint(remoteContext, Theme.UNSPECIFIED) - assertEquals(2, BuddyPreferences.getCoinsForWidget(context, widgetId)) - assertFalse(claimBox.isVisible) - } - - @Test - fun storeButton_clickLaunchesStoreActivityWithWidgetId() = runBlocking { - val widgetId = 42 - val doc = captureWidgetDocument(widgetId) - val remoteContext = TestRemoteContext(widgetId) - - doc.initializeContext(remoteContext) - doc.paint(remoteContext, Theme.UNSPECIFIED) - - // Click StoreButton center (x = 240 + 24 = 264, y = 240 + 24 = 264) - doc.onClick(remoteContext, 264f, 264f) - - val startedIntent = shadowOf(context as android.app.Application).nextStartedActivity - assertNotNull("Expected StoreButton click to start StoreActivity", startedIntent) - assertEquals(StoreActivity::class.java.name, startedIntent.component?.className) - assertEquals( - widgetId, - startedIntent.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ), - ) - assertEquals( - widgetId, - startedIntent.getIntExtra( - StoreActivity.EXTRA_WIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ), - ) - } - - @Test - fun storeActivity_singleWidget_displaysWidgetIdAndCoins() { - BuddyPreferences.saveCoinsForWidget(context, 42, 3) - - val intent = Intent(context, StoreActivity::class.java).apply { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 42) - } - ActivityScenario.launch(intent).use { scenario -> - scenario.onActivity { activity -> - val texts = collectSemanticsTexts(activity) - assertTrue("Expected 'Widget ID: 42' in $texts", texts.contains("Widget ID: 42")) - assertTrue("Expected 'Coins: 3' in $texts", texts.contains("Coins: 3")) - assertTrue("Expected 'Active Widget' in $texts", texts.contains("Active Widget")) - } - } - } - - @Test - fun storeActivity_multipleWidgets_displaysAllActiveWidgetsAndBalances() { - BuddyPreferences.saveCoinsForWidget(context, 101, 2) - BuddyPreferences.saveCoinsForWidget(context, 102, 5) - - val intent = Intent(context, StoreActivity::class.java) - ActivityScenario.launch(intent).use { scenario -> - scenario.onActivity { activity -> - val texts = collectSemanticsTexts(activity) - assertTrue("Expected 'Widget ID: 101' in $texts", texts.contains("Widget ID: 101")) - assertTrue("Expected 'Coins: 2' in $texts", texts.contains("Coins: 2")) - assertTrue("Expected 'Widget ID: 102' in $texts", texts.contains("Widget ID: 102")) - assertTrue("Expected 'Coins: 5' in $texts", texts.contains("Coins: 5")) - } - } - } - - @Test - fun storeActivity_noActiveWidgets_displaysEmptyStateMessage() { - val intent = Intent(context, StoreActivity::class.java) - ActivityScenario.launch(intent).use { scenario -> - scenario.onActivity { activity -> - val texts = collectSemanticsTexts(activity) - assertTrue( - "Expected empty state message in $texts", - texts.contains("No active Android Pet widgets"), - ) - } - } - } - - @Test - fun onDeleted_removesDeletedWidgetStateOnly() { - BuddyPreferences.saveCoinsForWidget(context, 101, 4) - BuddyPreferences.saveTapsCount(context, 101, 2) - BuddyPreferences.saveCoinsForWidget(context, 102, 7) - - AndroidPetWidget().onDeleted(context, intArrayOf(101)) - - assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 101)) - assertEquals(0, BuddyPreferences.getTapsCount(context, 101)) - assertEquals(7, BuddyPreferences.getCoinsForWidget(context, 102)) - assertEquals(listOf(102), BuddyPreferences.getActiveWidgetIds(context)) - } - - @Test - fun multipleWidgets_actionClickedBroadcasts_operateIndependentlyWithoutInterference() = runBlocking { - val widgetA = 10 - val widgetB = 20 - - // Capture both widgets (simulating widget A and widget B both pinned) - captureWidgetDocument(widgetA) - captureWidgetDocument(widgetB) - - // Claim action for widget A (slot 0 -> id = 10 * 1000 + 0 = 10000) - // Store action for widget B (slot 1 -> id = 20 * 1000 + 1 = 20001) - val receiver = AndroidPetWidget() - val claimIntentA = Intent(context, AndroidPetWidget::class.java).apply { - action = "androidx.compose.remote.ACTION_CLICKED" - putExtra("widgetId", widgetA) - putExtra("id", widgetA * 1000 + 0) - } - receiver.onReceive(context, claimIntentA) - - assertEquals(1, BuddyPreferences.getCoinsForWidget(context, widgetA)) - assertEquals(0, BuddyPreferences.getCoinsForWidget(context, widgetB)) - - // Now click StoreButton on widget B via ACTION_CLICKED without manually re-updating widget B first - val storeIntentB = Intent(context, AndroidPetWidget::class.java).apply { - action = "androidx.compose.remote.ACTION_CLICKED" - putExtra("widgetId", widgetB) - putExtra("id", widgetB * 1000 + 1) - } - receiver.onReceive(context, storeIntentB) - - val startedIntent = shadowOf(context as android.app.Application).nextStartedActivity - assertNotNull("Expected StoreButton click on widget B to start StoreActivity", startedIntent) - assertEquals( - widgetB, - startedIntent.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ), - ) - } - - @Test - fun assetsAndPreferences_edgeCases_decodeBitmapsAndClampNegativeValues() { - val claimBitmap = WidgetAssetProvider.claimCreditBitmap(context) - val storeBitmap = WidgetAssetProvider.storeBitmap(context) - assertTrue(claimBitmap.width > 0 && claimBitmap.height > 0) - assertTrue(storeBitmap.width > 0 && storeBitmap.height > 0) - - // Negative values clamp to 0 - BuddyPreferences.saveCoinsForWidget(context, 55, -10) - BuddyPreferences.saveTapsCount(context, 55, -5) - assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 55)) - assertEquals(0, BuddyPreferences.getTapsCount(context, 55)) - - // Invalid widget ID (0) is excluded from active widget IDs - BuddyPreferences.saveCoinsForWidget(context, AppWidgetManager.INVALID_APPWIDGET_ID, 3) - assertFalse( - BuddyPreferences.getActiveWidgetIds(context) - .contains(AppWidgetManager.INVALID_APPWIDGET_ID) - ) - } - - @Test - fun onUpdate_andUpdateAppWidget_populateLambdaActionsForAllWidgetsEvenWhenUnbound() { - val manager = AppWidgetManager.getInstance(context) - val widget = AndroidPetWidget() - - // Calling onUpdate with multiple unbound widget IDs in Robolectric must still - // compose Content and preserve registered WidgetLambdaActions for both widgets. - widget.onUpdate(context, manager, intArrayOf(10, 20)) - - RemoteWidgetActionBridge.runLambdaAction(10 * 1000 + 0) - assertEquals(1, BuddyPreferences.getCoinsForWidget(context, 10)) - assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 20)) - - RemoteWidgetActionBridge.runLambdaAction(20 * 1000 + 0) - assertEquals(1, BuddyPreferences.getCoinsForWidget(context, 10)) - assertEquals(1, BuddyPreferences.getCoinsForWidget(context, 20)) - - // updateAppWidget must also compose Content for a new widget ID and send - // ACTION_APPWIDGET_UPDATE guarded by EXTRA_ALREADY_UPDATED. - AndroidPetWidget.updateAppWidget(context, 30) - RemoteWidgetActionBridge.runLambdaAction(30 * 1000 + 0) - assertEquals(1, BuddyPreferences.getCoinsForWidget(context, 30)) - } - - @Test - fun directContentCapture_withoutExternalCompositionLocalProvider_registersCorrectWidgetId() = runBlocking { - val widgetId = 77 - val widget = AndroidPetWidget() - val captured = captureSingleRemoteDocument(context = context) { - widget.Content(context, widgetId) - } - val buffer = RemoteComposeBuffer.fromInputStream(ByteArrayInputStream(captured.bytes)) - val doc = CoreDocument().apply { - initFromBuffer(buffer) - } - val remoteContext = TestRemoteContext(widgetId) - doc.initializeContext(remoteContext) - doc.paint(remoteContext, Theme.UNSPECIFIED) - - // Click StoreButton center (264f, 264f) -> must trigger actionId 77001 (not -999) - doc.onClick(remoteContext, 264f, 264f) - assertEquals(listOf(widgetId * 1000 + 1), remoteContext.triggeredActionIds) - - val startedIntent = shadowOf(context as android.app.Application).nextStartedActivity - assertNotNull("Expected StoreButton click to start StoreActivity", startedIntent) - assertEquals( - widgetId, - startedIntent.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ), - ) - } - - @Test - fun storeActivity_livePreferenceUpdates_recomposeCoinBalanceImmediately() { - BuddyPreferences.saveCoinsForWidget(context, 42, 1) - - val intent = Intent(context, StoreActivity::class.java).apply { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 42) - } - ActivityScenario.launch(intent).use { scenario -> - scenario.onActivity { activity -> - val initialTexts = collectSemanticsTexts(activity) - assertTrue("Expected 'Coins: 1' in $initialTexts", initialTexts.contains("Coins: 1")) - - // Mutate preferences while StoreActivity is already resumed - BuddyPreferences.addCoinsForWidget(context, 42, 2) - val updatedTexts = collectSemanticsTexts(activity) - assertTrue("Expected 'Coins: 3' after live update in $updatedTexts", updatedTexts.contains("Coins: 3")) - } - } - } - - @Test - fun onUpdate_registersWidgetsWithZeroCoins_andStoreActivityDisplaysThem() { - val manager = AppWidgetManager.getInstance(context) - AndroidPetWidget().onUpdate(context, manager, intArrayOf(501, 502)) - - assertEquals(listOf(501, 502), BuddyPreferences.getActiveWidgetIds(context)) - assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 501)) - assertEquals(0, BuddyPreferences.getCoinsForWidget(context, 502)) - - // Launching StoreActivity must display both active widgets with 0 coins. - ActivityScenario.launch(StoreActivity::class.java).use { scenario -> - scenario.onActivity { activity -> - val texts = collectSemanticsTexts(activity) - assertTrue("Expected 'Widget ID: 501' in $texts", texts.contains("Widget ID: 501")) - assertTrue("Expected 'Widget ID: 502' in $texts", texts.contains("Widget ID: 502")) - assertTrue("Expected 'Coins: 0' in $texts", texts.contains("Coins: 0")) - } - } - } - - @Test - fun buddyRepository_andTapsCountInteroperability_syncCoinsAndTapsCount() { - val repo = BuddyRepository(context) - val widgetId = 88 - - assertEquals(0, repo.getCoinsForWidget(widgetId)) - assertEquals(0, repo.getTapsCount(widgetId)) - - // playWithBuddy increments both coins and tapsCount (GamingWidget compatibility) - val updated = repo.playWithBuddy(widgetId) - assertEquals(1, updated) - assertEquals(1, repo.getCoinsForWidget(widgetId)) - assertEquals(1, repo.getTapsCount(widgetId)) - assertEquals(1, repo.getWidgetBuddyState(widgetId).coins) - assertEquals(1, repo.getWidgetBuddyState(widgetId).tapsCount) - - // Saving via tapsCount alone (GamingWidget convention) is reflected by getCoinsForWidget - val otherWidgetId = 89 - BuddyPreferences.addTapsCount(context, otherWidgetId, 4) - assertEquals(4, BuddyPreferences.getCoinsForWidget(context, otherWidgetId)) - assertEquals(4, BuddyPreferences.getWidgetBuddyState(context, otherWidgetId).coins) - } - - private fun collectSemanticsTexts(activity: Activity): List { - shadowOf(Looper.getMainLooper()).idle() - val rootView = activity.window.decorView - val rootForTest = findRootForTest(rootView) - assertNotNull("Expected AndroidComposeView (RootForTest) in activity view hierarchy", rootForTest) - val texts = mutableListOf() - collectNodeTexts(rootForTest!!.semanticsOwner.unmergedRootSemanticsNode, texts) - return texts - } - - private fun findRootForTest(view: View): RootForTest? { - if (view is RootForTest) return view - if (view is ViewGroup) { - for (i in 0 until view.childCount) { - val found = findRootForTest(view.getChildAt(i)) - if (found != null) return found - } - } - return null - } - - private fun collectNodeTexts(node: SemanticsNode, out: MutableList) { - node.config.getOrNull(SemanticsProperties.Text)?.forEach { annotatedString -> - out.add(annotatedString.text) - } - node.children.forEach { child -> - collectNodeTexts(child, out) - } - } - - private suspend fun captureWidgetDocument(widgetId: Int): CoreDocument { - RemoteWidgetActionBridge.clearLambdaActions() - val widget = AndroidPetWidget() - val captured = captureSingleRemoteDocument(context = context) { - CompositionLocalProvider( - RemoteWidgetActionBridge.getLocalWidget() provides - RemoteWidgetActionBridge.createWidgetInformation(widgetId) - ) { - widget.Content(context, widgetId) - } - } - val buffer = RemoteComposeBuffer.fromInputStream(ByteArrayInputStream(captured.bytes)) - return CoreDocument().apply { - initFromBuffer(buffer) - } - } - - private fun findComponentByBounds( - component: Component, - x: Float, - y: Float, - w: Float, - h: Float, - ): Component? { - if ( - component is LayoutComponent && - component.x == x && - component.y == y && - component.width == w && - component.height == h - ) { - return component - } - for (op in component.list) { - if (op is Component) { - val found = findComponentByBounds(op, x, y, w, h) - if (found != null) return found - } - } - return null - } - - private fun describeTree(component: Component, indent: String = ""): String { - val sb = StringBuilder() - if (component is LayoutComponent) { - sb.appendLine("$indent${component.javaClass.simpleName}(id=${component.componentId}, x=${component.x}, y=${component.y}, w=${component.width}, h=${component.height}, vis=${component.isVisible})") - } else { - sb.appendLine("$indent${component.javaClass.simpleName}(id=${component.componentId}, vis=${component.isVisible})") - } - for (op in component.list) { - if (op is Component) { - sb.append(describeTree(op, "$indent ")) - } else { - sb.appendLine("$indent op: ${op.javaClass.simpleName} ($op)") - } - } - return sb.toString() - } - - private inner class TestRemoteContext(private val widgetId: Int) : RemoteContext() { - val triggeredActionIds = mutableListOf() - - init { - mWidth = 300f - mHeight = 300f - density = 1f - setAnimationEnabled(false) - mPaintContext = NoOpPaintContext(this) - } - - override fun loadPathData(instanceId: Int, winding: Int, floatPath: FloatArray) { - mRemoteComposeState.putPathData(instanceId, floatPath) - mRemoteComposeState.putPathWinding(instanceId, winding) - } - override fun getPathData(instanceId: Int): FloatArray? = - mRemoteComposeState.getPathData(instanceId) - override fun loadVariableName(varName: String, varId: Int, varType: Int) {} - override fun loadColor(id: Int, color: Int) { - mRemoteComposeState.updateColor(id, color) - } - override fun loadBitmap( - imageId: Int, - encoding: Short, - type: Short, - width: Int, - height: Int, - bitmap: ByteArray, - ) {} - override fun loadText(id: Int, text: String) { - if (!mRemoteComposeState.containsId(id)) { - mRemoteComposeState.cacheData(id, text) - } else { - mRemoteComposeState.updateData(id, text) - } - } - override fun getText(id: Int): String? = mRemoteComposeState.getFromId(id) as? String - override fun loadFloat(id: Int, value: Float) { - mRemoteComposeState.updateFloat(id, value) - } - override fun overrideFloat(id: Int, value: Float) { - mRemoteComposeState.overrideFloat(id, value) - } - override fun loadInteger(id: Int, value: Int) { - mRemoteComposeState.updateInteger(id, value) - } - override fun overrideInteger(id: Int, value: Int) { - mRemoteComposeState.overrideInteger(id, value) - } - override fun overrideText(id: Int, valueId: Int) { - val text = getText(valueId) ?: "" - mRemoteComposeState.overrideData(id, text) - } - override fun loadAnimatedFloat( - id: Int, - animatedFloat: androidx.compose.remote.core.operations.FloatExpression, - ) {} - override fun loadShader(id: Int, value: androidx.compose.remote.core.operations.ShaderData) {} - override fun getFloat(id: Int): Float = mRemoteComposeState.getFloat(id) - override fun putObject(id: Int, value: Any) { - mRemoteComposeState.updateObject(id, value) - } - override fun getObject(id: Int): Any? = mRemoteComposeState.getObject(id) - override fun getInteger(id: Int): Int = mRemoteComposeState.getInteger(id) - override fun getLong(id: Int): Long = 0L - override fun getColor(id: Int): Int = mRemoteComposeState.getColor(id) - override fun listensTo(id: Int, variableSupport: VariableSupport) { - mRemoteComposeState.listenToVar(id, variableSupport) - } - override fun updateOps(): Int = - mRemoteComposeState.getOpsToUpdate(this, System.currentTimeMillis()) - override fun getShader(id: Int): androidx.compose.remote.core.operations.ShaderData? = null - override fun addClickArea( - id: Int, - contentDescription: Int, - left: Float, - top: Float, - right: Float, - bottom: Float, - metadataId: Int, - ) {} - override fun runAction(id: Int, metadata: String) { - triggeredActionIds.add(id) - RemoteWidgetActionBridge.runLambdaAction(id) - } - override fun runNamedAction(id: Int, value: Any?) { - triggeredActionIds.add(id) - RemoteWidgetActionBridge.runLambdaAction(id) - } - override fun hapticEffect(type: Int) {} - override fun addCollection( - id: Int, - collection: androidx.compose.remote.core.operations.utilities.ArrayAccess, - ) {} - override fun putDataMap( - id: Int, - map: androidx.compose.remote.core.operations.utilities.DataMap, - ) {} - override fun getDataMap(id: Int): androidx.compose.remote.core.operations.utilities.DataMap? = null - override fun setNamedColorOverride(name: String, value: Int) {} - override fun setNamedStringOverride(name: String, value: String) {} - override fun clearNamedStringOverride(name: String) {} - override fun setNamedBooleanOverride(name: String, value: Boolean) {} - override fun clearNamedBooleanOverride(name: String) {} - override fun setNamedIntegerOverride(name: String, value: Int) {} - override fun clearNamedIntegerOverride(name: String) {} - override fun setNamedFloatOverride(name: String, value: Float) {} - override fun clearNamedFloatOverride(name: String) {} - override fun setNamedLong(name: String, value: Long) {} - override fun setNamedDataOverride(name: String, value: Any) {} - override fun clearNamedDataOverride(name: String) {} - } - - private class NoOpPaintContext(context: RemoteContext) : PaintContext(context) { - override fun roundedClipRect( - width: Float, - height: Float, - topStart: Float, - topEnd: Float, - bottomStart: Float, - bottomEnd: Float, - ) {} - override fun matrixFromPath(pathId: Int, fraction: Float, vOffset: Float, flags: Int) {} - override fun drawToBitmap(bitmapId: Int, mode: Int, color: Int) {} - override fun drawBitmap( - imageId: Int, - srcLeft: Int, - srcTop: Int, - srcRight: Int, - srcBottom: Int, - dstLeft: Int, - dstTop: Int, - dstRight: Int, - dstBottom: Int, - cdId: Int, - ) {} - override fun scale(scaleX: Float, scaleY: Float) {} - override fun translate(translateX: Float, translateY: Float) {} - override fun drawArc( - left: Float, - top: Float, - right: Float, - bottom: Float, - startAngle: Float, - sweepAngle: Float, - ) {} - override fun drawSector( - left: Float, - top: Float, - right: Float, - bottom: Float, - startAngle: Float, - sweepAngle: Float, - ) {} - override fun drawBitmap(id: Int, left: Float, top: Float, right: Float, bottom: Float) {} - override fun drawCircle(centerX: Float, centerY: Float, radius: Float) {} - override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) {} - override fun drawOval(left: Float, top: Float, right: Float, bottom: Float) {} - override fun drawPath(id: Int, start: Float, end: Float) {} - override fun drawRect(left: Float, top: Float, right: Float, bottom: Float) {} - override fun savePaint() {} - override fun restorePaint() {} - override fun replacePaint(paint: PaintBundle) {} - override fun drawRoundRect( - left: Float, - top: Float, - right: Float, - bottom: Float, - radiusX: Float, - radiusY: Float, - ) {} - override fun drawTextOnPath(textId: Int, pathId: Int, hOffset: Float, vOffset: Float) {} - override fun getTextBounds( - textId: Int, - start: Int, - end: Int, - flags: Int, - bounds: FloatArray, - ) { - bounds[0] = 0f - bounds[1] = 0f - bounds[2] = 50f - bounds[3] = 20f - } - override fun layoutComplexText( - textId: Int, - start: Int, - end: Int, - alignment: Int, - overflow: Int, - maxLines: Int, - maxWidth: Float, - maxHeight: Float, - letterSpacing: Float, - lineHeightAdd: Float, - lineHeightMultiplier: Float, - lineBreakStrategy: Int, - hyphenationFrequency: Int, - justificationMode: Int, - underline: Boolean, - strikethrough: Boolean, - flags: Int, - ): RcPlatformServices.ComputedTextLayout? = null - override fun drawComplexText(complexTextLayout: RcPlatformServices.ComputedTextLayout?) {} - override fun drawTextRun( - textId: Int, - start: Int, - end: Int, - contextStart: Int, - contextEnd: Int, - x: Float, - y: Float, - rtl: Boolean, - ) {} - override fun drawTweenPath( - path1Id: Int, - path2Id: Int, - tween: Float, - start: Float, - stop: Float, - ) {} - override fun tweenPath(out: Int, path1: Int, path2: Int, tween: Float) {} - override fun combinePath(out: Int, path1: Int, path2: Int, operation: Byte) {} - override fun applyPaint(paintData: PaintBundle) {} - override fun matrixScale(scaleX: Float, scaleY: Float, centerX: Float, centerY: Float) {} - override fun matrixTranslate(translateX: Float, translateY: Float) {} - override fun matrixSkew(skewX: Float, skewY: Float) {} - override fun matrixRotate(rotate: Float, pivotX: Float, pivotY: Float) {} - override fun matrixSave() {} - override fun matrixRestore() {} - override fun clipRect(left: Float, top: Float, right: Float, bottom: Float) {} - override fun clipPath(pathId: Int, regionOp: Int) {} - override fun reset() {} - override fun startGraphicsLayer(w: Int, h: Int) {} - override fun setGraphicsLayer(attributes: HashMap) {} - override fun endGraphicsLayer() {} - override fun getText(textId: Int): String? = mContext.getText(textId) - override fun log(content: String) {} - } -} From 3588d11b73b8fa4c9d96a770ab94a5fcea537214 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Mon, 12 Oct 2026 23:34:29 +0000 Subject: [PATCH 04/21] Remove redundant onReceive and manual updateAppWidget from AndroidPetWidget --- .../remote_compose/AndroidPetWidget.kt | 32 ------------------- .../remote_compose/BuddyPreferences.kt | 1 - 2 files changed, 33 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt index 4b0a9be7..155ff790 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt @@ -19,7 +19,6 @@ package com.example.platform.ui.appwidgets.remote_compose import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.Context -import android.content.Intent import androidx.compose.remote.creation.compose.action.valueChange import androidx.compose.remote.creation.compose.layout.RemoteAlignment import androidx.compose.remote.creation.compose.layout.RemoteBox @@ -44,16 +43,6 @@ import androidx.compose.ui.graphics.Color @SuppressLint("RestrictedApiAndroidX", "RestrictedApi") class AndroidPetWidget : RemoteComposeWidget() { - override fun onReceive(context: Context, intent: Intent) { - if ( - intent.action == AppWidgetManager.ACTION_APPWIDGET_UPDATE && - intent.getBooleanExtra(EXTRA_ALREADY_UPDATED, false) - ) { - return - } - super.onReceive(context, intent) - } - override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, @@ -137,26 +126,5 @@ class AndroidPetWidget : RemoteComposeWidget() { companion object { const val REQUIRED_TAPS_FOR_COIN = 3 - private const val EXTRA_ALREADY_UPDATED = - "com.example.platform.ui.appwidgets.remote_compose.EXTRA_ALREADY_UPDATED" - - fun updateAppWidget( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetId: Int, - ) { - AndroidPetWidget().onUpdate(context, appWidgetManager, intArrayOf(appWidgetId)) - val intent = Intent(context, AndroidPetWidget::class.java).apply { - action = AppWidgetManager.ACTION_APPWIDGET_UPDATE - putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId)) - putExtra(EXTRA_ALREADY_UPDATED, true) - } - context.sendBroadcast(intent) - } - - fun updateAppWidget(context: Context, appWidgetId: Int) { - val manager = AppWidgetManager.getInstance(context) - updateAppWidget(context, manager, appWidgetId) - } } } diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt index 1bfe55a7..1e1ace81 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt @@ -172,7 +172,6 @@ class BuddyRepository(context: Context) { fun playWithBuddy(widgetId: Int): Int { val updated = BuddyPreferences.addCoinsForWidget(appContext, widgetId, 1) BuddyPreferences.saveTapsCount(appContext, widgetId, updated) - AndroidPetWidget.updateAppWidget(appContext, widgetId) return updated } From 28c798364038e7512c7a46dee628bdb99c7cdbb5 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Mon, 12 Oct 2026 23:47:31 +0000 Subject: [PATCH 05/21] Simplify widget registration and clarify state comments in AndroidPetWidget --- .../remote_compose/AndroidPetWidget.kt | 12 +++---- .../remote_compose/BuddyPreferences.kt | 32 +++---------------- 2 files changed, 9 insertions(+), 35 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt index 155ff790..47d4d483 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt @@ -48,11 +48,6 @@ class AndroidPetWidget : RemoteComposeWidget() { appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { - for (id in appWidgetIds) { - if (id != AppWidgetManager.INVALID_APPWIDGET_ID) { - BuddyPreferences.ensureWidgetRegistered(context, id) - } - } super.onUpdate(context, appWidgetManager, appWidgetIds) } @@ -78,15 +73,18 @@ class AndroidPetWidget : RemoteComposeWidget() { val petTaps = rememberMutableRemoteInt(0) val nextPetTaps = petTaps + 1.ri + // Evaluates to 1 (visible) in the host's expression graph when petTaps >= 3, + // or 0 (hidden) otherwise, allowing the coin button to appear after 3 taps + // without waking the app process. val isClaimVisible = selectIfGe(petTaps, REQUIRED_TAPS_FOR_COIN.ri, 1.ri, 0.ri) RemoteBox( modifier = RemoteModifier.fillMaxSize().background(Color.White.rc), contentAlignment = RemoteAlignment.Center ) { - // Materialize RemoteInt expression IDs in the active creation state before - // action modifiers serialize them into nested action lists. RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { + // Register the local state variables and expressions in the Remote Document + // before they are used inside the onTouchDown action below. petTaps.getIdForCreationState(remoteComposeCreationState) nextPetTaps.getIdForCreationState(remoteComposeCreationState) isClaimVisible.getIdForCreationState(remoteComposeCreationState) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt index 1e1ace81..2142cb00 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt @@ -88,16 +88,6 @@ object BuddyPreferences { return updated } - fun ensureWidgetRegistered(context: Context, widgetId: Int) { - if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return - val p = prefs(context) - val coinsKey = "$KEY_COINS_PREFIX$widgetId" - val tapsKey = "$KEY_TAPS_PREFIX$widgetId" - if (!p.contains(coinsKey) && !p.contains(tapsKey)) { - p.edit().putInt(coinsKey, 0).apply() - } - } - fun getWidgetBuddyState(context: Context, widgetId: Int): WidgetBuddyState = WidgetBuddyState( widgetId = widgetId, @@ -113,27 +103,13 @@ object BuddyPreferences { } /** - * Returns all active widget IDs from [AppWidgetManager] combined with any widget IDs - * that have persisted state in [BuddyPreferences]. + * Returns all active widget IDs from [AppWidgetManager]. */ fun getActiveWidgetIds(context: Context): List { - val managerIds = runCatching { - val manager = AppWidgetManager.getInstance(context) - val component = ComponentName(context, AndroidPetWidget::class.java) - manager.getAppWidgetIds(component).toList() - }.getOrDefault(emptyList()) - - val prefIds = prefs(context).all.keys.mapNotNull { key -> - when { - key.startsWith(KEY_COINS_PREFIX) -> key.removePrefix(KEY_COINS_PREFIX).toIntOrNull() - key.startsWith(KEY_TAPS_PREFIX) -> key.removePrefix(KEY_TAPS_PREFIX).toIntOrNull() - else -> null - } - } - - return (managerIds + prefIds) + val manager = AppWidgetManager.getInstance(context) + val component = ComponentName(context, AndroidPetWidget::class.java) + return (manager.getAppWidgetIds(component) ?: IntArray(0)) .filter { it != AppWidgetManager.INVALID_APPWIDGET_ID } - .distinct() .sorted() } From b95dd8e47d77739ad0336ab426163de049986a6f Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Mon, 12 Oct 2026 23:58:26 +0000 Subject: [PATCH 06/21] Remove unnecessary getIdForCreationState RemoteCanvas block from AndroidPetWidget --- .../ui/appwidgets/remote_compose/AndroidPetWidget.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt index 47d4d483..f8852e16 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt @@ -22,7 +22,6 @@ import android.content.Context import androidx.compose.remote.creation.compose.action.valueChange import androidx.compose.remote.creation.compose.layout.RemoteAlignment import androidx.compose.remote.creation.compose.layout.RemoteBox -import androidx.compose.remote.creation.compose.layout.RemoteCanvas import androidx.compose.remote.creation.compose.layout.RemoteColumn import androidx.compose.remote.creation.compose.layout.RemoteComposable import androidx.compose.remote.creation.compose.layout.RemoteText @@ -82,14 +81,6 @@ class AndroidPetWidget : RemoteComposeWidget() { modifier = RemoteModifier.fillMaxSize().background(Color.White.rc), contentAlignment = RemoteAlignment.Center ) { - RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { - // Register the local state variables and expressions in the Remote Document - // before they are used inside the onTouchDown action below. - petTaps.getIdForCreationState(remoteComposeCreationState) - nextPetTaps.getIdForCreationState(remoteComposeCreationState) - isClaimVisible.getIdForCreationState(remoteComposeCreationState) - } - // Pet interactive touch target (placed below WidgetActionBar so button clicks // on the action bar are not intercepted by the pet touch target). RemoteBox( From 00b90fc77a203773c7d3c6e094bf8c7231fdd092 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 00:21:03 +0000 Subject: [PATCH 07/21] Simplify BuddyPreferences by removing BuddyRepository and redundant tap keys --- .../remote_compose/AndroidPetWidget.kt | 3 +- .../remote_compose/BuddyPreferences.kt | 121 ++---------------- .../remote_compose/ClaimCreditButton.kt | 2 +- .../remote_compose/StoreActivity.kt | 1 - 4 files changed, 16 insertions(+), 111 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt index f8852e16..529a55ab 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt @@ -81,8 +81,7 @@ class AndroidPetWidget : RemoteComposeWidget() { modifier = RemoteModifier.fillMaxSize().background(Color.White.rc), contentAlignment = RemoteAlignment.Center ) { - // Pet interactive touch target (placed below WidgetActionBar so button clicks - // on the action bar are not intercepted by the pet touch target). + // Declared before the action buttons so the buttons sit on top of the pet touch area. RemoteBox( modifier = RemoteModifier .fillMaxSize() diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt index 2142cb00..5ac9d078 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt @@ -26,140 +26,47 @@ import android.content.SharedPreferences */ data class WidgetBuddyState( val widgetId: Int, - val tapsCount: Int = 0, val coins: Int = 0, -) { - val appWidgetId: Int - get() = widgetId -} +) /** - * Persists per-widget tap counts and collected coins in [SharedPreferences]. + * Persists per-widget collected coins in [SharedPreferences]. */ object BuddyPreferences { const val PREFS_NAME = "buddy_prefs" - private const val KEY_TAPS_PREFIX = "taps_count_" private const val KEY_COINS_PREFIX = "widget_coins_" fun prefs(context: Context): SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - fun getTapsCount(context: Context, widgetId: Int): Int { - val p = prefs(context) - val tapsKey = "$KEY_TAPS_PREFIX$widgetId" - return if (p.contains(tapsKey)) { - p.getInt(tapsKey, 0) - } else { - p.getInt("$KEY_COINS_PREFIX$widgetId", 0) - } - } - - fun saveTapsCount(context: Context, widgetId: Int, count: Int) { - prefs(context).edit() - .putInt("$KEY_TAPS_PREFIX$widgetId", count.coerceAtLeast(0)) - .apply() - } - - fun addTapsCount(context: Context, widgetId: Int, delta: Int = 1): Int { - val updated = (getTapsCount(context, widgetId) + delta).coerceAtLeast(0) - saveTapsCount(context, widgetId, updated) - return updated - } - - fun getCoinsForWidget(context: Context, widgetId: Int): Int { - val p = prefs(context) - val coinsKey = "$KEY_COINS_PREFIX$widgetId" - return if (p.contains(coinsKey)) { - p.getInt(coinsKey, 0) - } else { - p.getInt("$KEY_TAPS_PREFIX$widgetId", 0) - } - } - - fun saveCoinsForWidget(context: Context, widgetId: Int, coins: Int) { - prefs(context).edit() - .putInt("$KEY_COINS_PREFIX$widgetId", coins.coerceAtLeast(0)) - .apply() - } + fun getCoinsForWidget(context: Context, widgetId: Int): Int = + prefs(context).getInt("$KEY_COINS_PREFIX$widgetId", 0) fun addCoinsForWidget(context: Context, widgetId: Int, amount: Int = 1): Int { val updated = (getCoinsForWidget(context, widgetId) + amount).coerceAtLeast(0) - saveCoinsForWidget(context, widgetId, updated) + prefs(context).edit() + .putInt("$KEY_COINS_PREFIX$widgetId", updated) + .apply() return updated } - fun getWidgetBuddyState(context: Context, widgetId: Int): WidgetBuddyState = - WidgetBuddyState( - widgetId = widgetId, - tapsCount = getTapsCount(context, widgetId), - coins = getCoinsForWidget(context, widgetId), - ) - fun removeWidgetState(context: Context, widgetId: Int) { prefs(context).edit() - .remove("$KEY_TAPS_PREFIX$widgetId") .remove("$KEY_COINS_PREFIX$widgetId") .apply() } - /** - * Returns all active widget IDs from [AppWidgetManager]. - */ - fun getActiveWidgetIds(context: Context): List { + fun getActiveBuddyStates(context: Context): List { val manager = AppWidgetManager.getInstance(context) val component = ComponentName(context, AndroidPetWidget::class.java) return (manager.getAppWidgetIds(component) ?: IntArray(0)) .filter { it != AppWidgetManager.INVALID_APPWIDGET_ID } .sorted() + .map { id -> + WidgetBuddyState( + widgetId = id, + coins = getCoinsForWidget(context, id), + ) + } } - - fun getActiveBuddyStates(context: Context): List = - getActiveWidgetIds(context).map { id -> - getWidgetBuddyState(context, id) - } - - fun getActiveBuddies(context: Context): List = - getActiveBuddyStates(context) -} - -/** - * Repository wrapper over [BuddyPreferences] and [AppWidgetManager] for querying and updating - * Android Pet widget state. - */ -class BuddyRepository(context: Context) { - private val appContext = context.applicationContext ?: context - - fun getActiveWidgetIds(): List = BuddyPreferences.getActiveWidgetIds(appContext) - - fun getActiveBuddies(): List = BuddyPreferences.getActiveWidgetIds(appContext) - - fun getActiveBuddyStates(): List = - BuddyPreferences.getActiveBuddyStates(appContext) - - fun getWidgetBuddyState(widgetId: Int): WidgetBuddyState = - BuddyPreferences.getWidgetBuddyState(appContext, widgetId) - - fun getCoinsForWidget(widgetId: Int): Int = - BuddyPreferences.getCoinsForWidget(appContext, widgetId) - - fun addCoinsForWidget(widgetId: Int, amount: Int = 1): Int = - BuddyPreferences.addCoinsForWidget(appContext, widgetId, amount) - - fun playWithBuddy(widgetId: Int): Int { - val updated = BuddyPreferences.addCoinsForWidget(appContext, widgetId, 1) - BuddyPreferences.saveTapsCount(appContext, widgetId, updated) - return updated - } - - fun getTapsCount(widgetId: Int): Int = - BuddyPreferences.getTapsCount(appContext, widgetId) - - fun saveTapsCount(widgetId: Int, count: Int) = - BuddyPreferences.saveTapsCount(appContext, widgetId, count) - - fun removeWidget(widgetId: Int) = - BuddyPreferences.removeWidgetState(appContext, widgetId) - - fun removeWidgetState(widgetId: Int) = - BuddyPreferences.removeWidgetState(appContext, widgetId) } diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt index fdb40faf..a6af2c77 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt @@ -67,7 +67,7 @@ fun ClaimCreditButton( RemoteBox( modifier = buttonModifier .onClick { - BuddyRepository(context).playWithBuddy(widgetId) + BuddyPreferences.addCoinsForWidget(context, widgetId) }, ) { RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt index 9ae7611a..e7bf5389 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -278,7 +278,6 @@ private fun loadWidgetStates( states.add( WidgetBuddyState( widgetId = selectedWidgetId, - tapsCount = BuddyPreferences.getTapsCount(context, selectedWidgetId), coins = BuddyPreferences.getCoinsForWidget(context, selectedWidgetId), ) ) From 099eaa77a00463817c3b3af53b39c5e35ba201e0 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 00:39:54 +0000 Subject: [PATCH 08/21] Remove redundant onUpdate override from AndroidPetWidget --- .../ui/appwidgets/remote_compose/AndroidPetWidget.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt index 529a55ab..9210680d 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt @@ -17,7 +17,6 @@ package com.example.platform.ui.appwidgets.remote_compose import android.annotation.SuppressLint -import android.appwidget.AppWidgetManager import android.content.Context import androidx.compose.remote.creation.compose.action.valueChange import androidx.compose.remote.creation.compose.layout.RemoteAlignment @@ -42,14 +41,6 @@ import androidx.compose.ui.graphics.Color @SuppressLint("RestrictedApiAndroidX", "RestrictedApi") class AndroidPetWidget : RemoteComposeWidget() { - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - super.onUpdate(context, appWidgetManager, appWidgetIds) - } - override fun onDeleted(context: Context, appWidgetIds: IntArray) { super.onDeleted(context, appWidgetIds) appWidgetIds.forEach { id -> From 5aabd57c67c10f881ca729ba4788dbf0a77fcc1a Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 00:48:27 +0000 Subject: [PATCH 09/21] Simplify ClaimCreditButton, StoreButton, and WidgetActionBar --- .../remote_compose/AndroidPetWidget.kt | 9 ---- .../remote_compose/ClaimCreditButton.kt | 44 ++++++------------- .../appwidgets/remote_compose/StoreButton.kt | 44 +++++-------------- .../remote_compose/WidgetActionBar.kt | 34 +++++++------- 4 files changed, 45 insertions(+), 86 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt index 9210680d..2ccdd759 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt @@ -35,7 +35,6 @@ import androidx.compose.remote.creation.compose.state.rs import androidx.compose.remote.creation.compose.state.selectIfGe import androidx.compose.remote.creation.compose.widgets.RemoteComposeWidget import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color @SuppressLint("RestrictedApiAndroidX", "RestrictedApi") @@ -53,12 +52,6 @@ class AndroidPetWidget : RemoteComposeWidget() { @Composable override fun Content(context: Context, widgetId: Int) { val appContext = context.applicationContext ?: context - val claimCreditIconBitmap = remember(appContext) { - WidgetAssetProvider.claimCreditBitmap(appContext) - } - val storeIconBitmap = remember(appContext) { - WidgetAssetProvider.storeBitmap(appContext) - } val coins = BuddyPreferences.getCoinsForWidget(appContext, widgetId) val petTaps = rememberMutableRemoteInt(0) @@ -96,8 +89,6 @@ class AndroidPetWidget : RemoteComposeWidget() { WidgetActionBar( context = appContext, widgetId = widgetId, - claimCreditIconBitmap = claimCreditIconBitmap, - storeIconBitmap = storeIconBitmap, isClaimVisible = isClaimVisible, ) } diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt index a6af2c77..304daed8 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt @@ -18,8 +18,6 @@ package com.example.platform.ui.appwidgets.remote_compose import android.annotation.SuppressLint import android.content.Context -import android.graphics.Bitmap -import androidx.compose.remote.creation.compose.layout.RemoteAlignment import androidx.compose.remote.creation.compose.layout.RemoteBox import androidx.compose.remote.creation.compose.layout.RemoteCanvas import androidx.compose.remote.creation.compose.layout.RemoteComposable @@ -42,40 +40,26 @@ import androidx.compose.ui.graphics.asImageBitmap fun ClaimCreditButton( context: Context, widgetId: Int, - claimCreditIconBitmap: Bitmap? = null, - isClaimVisible: RemoteInt? = null, + isClaimVisible: RemoteInt, ) { - val bitmap = claimCreditIconBitmap ?: remember { + val bitmap = remember(context) { WidgetAssetProvider.claimCreditBitmap(context) } RemoteBox( - contentAlignment = RemoteAlignment.BottomEnd, - modifier = RemoteModifier.fillMaxSize(), + modifier = RemoteModifier + .padding(end = 68.rdp, bottom = 12.rdp) + .size(48.rdp) + .visibility(isClaimVisible) + .onClick { + BuddyPreferences.addCoinsForWidget(context, widgetId) + }, ) { - val buttonModifier = if (isClaimVisible != null) { - RemoteModifier - .padding(end = 68.rdp, bottom = 12.rdp) - .size(48.rdp) - .visibility(isClaimVisible) - } else { - RemoteModifier - .padding(end = 68.rdp, bottom = 12.rdp) - .size(48.rdp) - } - - RemoteBox( - modifier = buttonModifier - .onClick { - BuddyPreferences.addCoinsForWidget(context, widgetId) - }, - ) { - RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { - drawScaledBitmap( - image = bitmap.asImageBitmap().rb, - contentDescription = "Claim Credit", - ) - } + RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { + drawScaledBitmap( + image = bitmap.asImageBitmap().rb, + contentDescription = "Claim Credit", + ) } } } diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt index 92719e72..4e8f4f51 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt @@ -21,9 +21,7 @@ import android.app.ActivityOptions import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent -import android.graphics.Bitmap import android.os.Build -import androidx.compose.remote.creation.compose.layout.RemoteAlignment import androidx.compose.remote.creation.compose.layout.RemoteBox import androidx.compose.remote.creation.compose.layout.RemoteCanvas import androidx.compose.remote.creation.compose.layout.RemoteComposable @@ -31,8 +29,6 @@ import androidx.compose.remote.creation.compose.modifier.RemoteModifier import androidx.compose.remote.creation.compose.modifier.fillMaxSize import androidx.compose.remote.creation.compose.modifier.padding import androidx.compose.remote.creation.compose.modifier.size -import androidx.compose.remote.creation.compose.modifier.visibility -import androidx.compose.remote.creation.compose.state.RemoteInt import androidx.compose.remote.creation.compose.state.rb import androidx.compose.remote.creation.compose.state.rdp import androidx.compose.remote.creation.compose.widgets.onClick @@ -46,40 +42,24 @@ import androidx.compose.ui.graphics.asImageBitmap fun StoreButton( context: Context, widgetId: Int, - storeIconBitmap: Bitmap? = null, - isStoreVisible: RemoteInt? = null, ) { - val bitmap = storeIconBitmap ?: remember { + val bitmap = remember(context) { WidgetAssetProvider.storeBitmap(context) } RemoteBox( - contentAlignment = RemoteAlignment.BottomEnd, - modifier = RemoteModifier.fillMaxSize(), + modifier = RemoteModifier + .padding(12.rdp) + .size(48.rdp) + .onClick { + launchStoreActivity(context, widgetId) + }, ) { - val buttonModifier = if (isStoreVisible != null) { - RemoteModifier - .padding(12.rdp) - .size(48.rdp) - .visibility(isStoreVisible) - } else { - RemoteModifier - .padding(12.rdp) - .size(48.rdp) - } - - RemoteBox( - modifier = buttonModifier - .onClick { - launchStoreActivity(context, widgetId) - }, - ) { - RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { - drawScaledBitmap( - image = bitmap.asImageBitmap().rb, - contentDescription = "Store", - ) - } + RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { + drawScaledBitmap( + image = bitmap.asImageBitmap().rb, + contentDescription = "Store", + ) } } } diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt index 0dcb573c..ec81ada2 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetActionBar.kt @@ -18,8 +18,11 @@ package com.example.platform.ui.appwidgets.remote_compose import android.annotation.SuppressLint import android.content.Context -import android.graphics.Bitmap +import androidx.compose.remote.creation.compose.layout.RemoteAlignment +import androidx.compose.remote.creation.compose.layout.RemoteBox import androidx.compose.remote.creation.compose.layout.RemoteComposable +import androidx.compose.remote.creation.compose.modifier.RemoteModifier +import androidx.compose.remote.creation.compose.modifier.fillMaxSize import androidx.compose.remote.creation.compose.state.RemoteInt import androidx.compose.runtime.Composable @@ -29,19 +32,20 @@ import androidx.compose.runtime.Composable fun WidgetActionBar( context: Context, widgetId: Int, - claimCreditIconBitmap: Bitmap? = null, - storeIconBitmap: Bitmap? = null, - isClaimVisible: RemoteInt? = null, + isClaimVisible: RemoteInt, ) { - ClaimCreditButton( - context = context, - widgetId = widgetId, - claimCreditIconBitmap = claimCreditIconBitmap, - isClaimVisible = isClaimVisible, - ) - StoreButton( - context = context, - widgetId = widgetId, - storeIconBitmap = storeIconBitmap, - ) + RemoteBox( + contentAlignment = RemoteAlignment.BottomEnd, + modifier = RemoteModifier.fillMaxSize(), + ) { + ClaimCreditButton( + context = context, + widgetId = widgetId, + isClaimVisible = isClaimVisible, + ) + StoreButton( + context = context, + widgetId = widgetId, + ) + } } From 310a809663209bf713be494efc1746b5b443777e Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 00:56:06 +0000 Subject: [PATCH 10/21] Simplify StoreActivity state loading and card UI --- .../remote_compose/StoreActivity.kt | 151 ++---------------- .../appwidgets/remote_compose/StoreButton.kt | 1 - 2 files changed, 15 insertions(+), 137 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt index e7bf5389..89415c55 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -16,10 +16,6 @@ package com.example.platform.ui.appwidgets.remote_compose -import android.appwidget.AppWidgetManager -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -36,28 +32,20 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import com.example.platform.ui.appwidgets.R /** @@ -66,94 +54,28 @@ import com.example.platform.ui.appwidgets.R */ class StoreActivity : ComponentActivity() { - private var selectedWidgetId by mutableIntStateOf(AppWidgetManager.INVALID_APPWIDGET_ID) + private var activeStates by mutableStateOf>(emptyList()) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - selectedWidgetId = extractWidgetId(intent) setContent { MaterialTheme { - StoreScreen( - selectedWidgetId = selectedWidgetId, - onBack = { finish() }, - ) + StoreScreen(activeStates = activeStates) } } } - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - setIntent(intent) - selectedWidgetId = extractWidgetId(intent) - } - - private fun extractWidgetId(intent: Intent?): Int { - if (intent == null) return AppWidgetManager.INVALID_APPWIDGET_ID - val fromStandardExtra = intent.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ) - if (fromStandardExtra != AppWidgetManager.INVALID_APPWIDGET_ID) { - return fromStandardExtra - } - return intent.getIntExtra(EXTRA_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) - } - - companion object { - const val EXTRA_WIDGET_ID = "appWidgetId" - - fun start( - context: Context, - widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID, - ) { - val intent = Intent(context, StoreActivity::class.java).apply { - if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) - putExtra(EXTRA_WIDGET_ID, widgetId) - } - } - context.startActivity(intent) - } + override fun onResume() { + super.onResume() + activeStates = BuddyPreferences.getActiveBuddyStates(this) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun StoreScreen( - selectedWidgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID, - buddyStates: List? = null, - onBack: (() -> Unit)? = null, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - var loadedStates by remember(context, selectedWidgetId) { - mutableStateOf(loadWidgetStates(context, selectedWidgetId)) - } - - DisposableEffect(lifecycleOwner, context, selectedWidgetId, buddyStates) { - val prefs = BuddyPreferences.prefs(context) - val prefListener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> - if (buddyStates == null) { - loadedStates = loadWidgetStates(context, selectedWidgetId) - } - } - prefs.registerOnSharedPreferenceChangeListener(prefListener) - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && buddyStates == null) { - loadedStates = loadWidgetStates(context, selectedWidgetId) - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - prefs.unregisterOnSharedPreferenceChangeListener(prefListener) - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - val activeStates = buddyStates ?: loadedStates - +fun StoreScreen(activeStates: List) { Scaffold( topBar = { TopAppBar( @@ -182,9 +104,7 @@ fun StoreScreen( ) { if (activeStates.isEmpty()) { item { - Card( - modifier = Modifier.fillMaxWidth(), - ) { + Card(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -203,10 +123,7 @@ fun StoreScreen( } } else { items(activeStates, key = { it.widgetId }) { state -> - WidgetCoinCard( - state = state, - isSelected = state.widgetId == selectedWidgetId, - ) + WidgetCoinCard(state = state) } } } @@ -214,19 +131,8 @@ fun StoreScreen( } @Composable -private fun WidgetCoinCard( - state: WidgetBuddyState, - isSelected: Boolean, -) { - val containerColor = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - } - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = containerColor), - ) { +private fun WidgetCoinCard(state: WidgetBuddyState) { + Card(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier .fillMaxWidth() @@ -234,19 +140,11 @@ private fun WidgetCoinCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = "Widget ID: ${state.widgetId}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - if (isSelected) { - Text( - text = "Active Widget", - style = MaterialTheme.typography.labelMedium, - ) - } - } + Text( + text = "Widget ID: ${state.widgetId}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -265,22 +163,3 @@ private fun WidgetCoinCard( } } } - -private fun loadWidgetStates( - context: Context, - selectedWidgetId: Int, -): List { - val states = BuddyPreferences.getActiveBuddyStates(context).toMutableList() - if ( - selectedWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID && - states.none { it.widgetId == selectedWidgetId } - ) { - states.add( - WidgetBuddyState( - widgetId = selectedWidgetId, - coins = BuddyPreferences.getCoinsForWidget(context, selectedWidgetId), - ) - ) - } - return states.sortedBy { it.widgetId } -} diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt index 4e8f4f51..6fd6b512 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt @@ -67,7 +67,6 @@ fun StoreButton( internal fun launchStoreActivity(context: Context, widgetId: Int) { val intent = Intent(context, StoreActivity::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) - putExtra(StoreActivity.EXTRA_WIDGET_ID, widgetId) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP From 6d279cf05c9c2722d990a876cc294e650fdf1579 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 17:22:50 +0000 Subject: [PATCH 11/21] Match StoreActivity visual design to PetBuddyStore --- .../remote_compose/StoreActivity.kt | 634 ++++++++++++++++-- .../main/res/drawable/rounded_storefront.xml | 20 + .../src/main/res/font/madimi_one_regular.ttf | Bin 0 -> 98100 bytes 3 files changed, 588 insertions(+), 66 deletions(-) create mode 100644 samples/user-interface/appwidgets/src/main/res/drawable/rounded_storefront.xml create mode 100644 samples/user-interface/appwidgets/src/main/res/font/madimi_one_regular.ttf diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt index 89415c55..30e3dbdf 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -16,38 +16,297 @@ package com.example.platform.ui.appwidgets.remote_compose +import android.appwidget.AppWidgetManager +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.Image +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.example.platform.ui.appwidgets.R +val MadimiOneFontFamily = FontFamily( + Font(R.font.madimi_one_regular, FontWeight.Normal), + Font(R.font.madimi_one_regular, FontWeight.Medium), + Font(R.font.madimi_one_regular, FontWeight.SemiBold), + Font(R.font.madimi_one_regular, FontWeight.Bold), + Font(R.font.madimi_one_regular, FontWeight.ExtraBold), +) + +/** + * Brand color tokens for the modern, playful Pet Buddy Store aesthetic. + */ +object StoreColors { + // Light Mode Tokens + val BackgroundLight = Color(0xFFF5F7F0) + val SurfaceCardLight = Color(0xFFFFFFFF) + val RoomFrameBgLight = Color(0xFF3B4D5B) + val PrimaryLight = Color(0xFF2E6B4E) + val SecondaryContainerLight = Color(0xFFE8F3EC) + val OwnedContainerLight = Color(0xFFEAF7EE) + val OwnedBorderLight = Color(0xFF4CAF50) + val CoinBadgeBgLight = Color(0xFFFFF3CD) + val CoinBadgeTextLight = Color(0xFF7A5200) + val DiscoAccentBgLight = Color(0xFF4A154B) + val DiscoAccentBorder = Color(0xFFFFD54F) + val DisabledContainerLight = Color(0xFFF0F1EE) + val TitleTextLight = Color(0xFF263329) + val SubtitleTextLight = Color(0xFF526056) + + // Dark Mode Tokens + val BackgroundDark = Color(0xFF141815) + val SurfaceCardDark = Color(0xFF1E2420) + val RoomFrameBgDark = Color(0xFF263540) + val PrimaryDark = Color(0xFF81C784) + val SecondaryContainerDark = Color(0xFF25382E) + val OwnedContainerDark = Color(0xFF1F3528) + val OwnedBorderDark = Color(0xFF66BB6A) + val CoinBadgeBgDark = Color(0xFF3D3112) + val CoinBadgeTextDark = Color(0xFFFFD54F) + val DiscoAccentBgDark = Color(0xFF5C1D5E) + val DisabledContainerDark = Color(0xFF252826) + val TitleTextDark = Color(0xFFE8F0EA) + val SubtitleTextDark = Color(0xFFA6B5AA) +} + +@Immutable +data class StorePalette( + val background: Color, + val surfaceCard: Color, + val roomFrameBg: Color, + val primary: Color, + val secondaryContainer: Color, + val ownedContainer: Color, + val ownedBorder: Color, + val coinBadgeBg: Color, + val coinBadgeText: Color, + val discoAccentBg: Color, + val discoAccentBorder: Color, + val disabledContainer: Color, + val titleText: Color, + val subtitleText: Color, +) + +private val LightStorePalette = StorePalette( + background = StoreColors.BackgroundLight, + surfaceCard = StoreColors.SurfaceCardLight, + roomFrameBg = StoreColors.RoomFrameBgLight, + primary = StoreColors.PrimaryLight, + secondaryContainer = StoreColors.SecondaryContainerLight, + ownedContainer = StoreColors.OwnedContainerLight, + ownedBorder = StoreColors.OwnedBorderLight, + coinBadgeBg = StoreColors.CoinBadgeBgLight, + coinBadgeText = StoreColors.CoinBadgeTextLight, + discoAccentBg = StoreColors.DiscoAccentBgLight, + discoAccentBorder = StoreColors.DiscoAccentBorder, + disabledContainer = StoreColors.DisabledContainerLight, + titleText = StoreColors.TitleTextLight, + subtitleText = StoreColors.SubtitleTextLight, +) + +private val DarkStorePalette = StorePalette( + background = StoreColors.BackgroundDark, + surfaceCard = StoreColors.SurfaceCardDark, + roomFrameBg = StoreColors.RoomFrameBgDark, + primary = StoreColors.PrimaryDark, + secondaryContainer = StoreColors.SecondaryContainerDark, + ownedContainer = StoreColors.OwnedContainerDark, + ownedBorder = StoreColors.OwnedBorderDark, + coinBadgeBg = StoreColors.CoinBadgeBgDark, + coinBadgeText = StoreColors.CoinBadgeTextDark, + discoAccentBg = StoreColors.DiscoAccentBgDark, + discoAccentBorder = StoreColors.DiscoAccentBorder, + disabledContainer = StoreColors.DisabledContainerDark, + titleText = StoreColors.TitleTextDark, + subtitleText = StoreColors.SubtitleTextDark, +) + +val LocalStorePalette = staticCompositionLocalOf { LightStorePalette } + +private val StoreLightColorScheme = lightColorScheme( + primary = StoreColors.PrimaryLight, + onPrimary = Color.White, + primaryContainer = StoreColors.SecondaryContainerLight, + onPrimaryContainer = StoreColors.TitleTextLight, + secondary = StoreColors.RoomFrameBgLight, + onSecondary = Color.White, + secondaryContainer = StoreColors.SecondaryContainerLight, + onSecondaryContainer = StoreColors.TitleTextLight, + background = StoreColors.BackgroundLight, + onBackground = StoreColors.TitleTextLight, + surface = StoreColors.SurfaceCardLight, + onSurface = StoreColors.TitleTextLight, + surfaceVariant = StoreColors.SecondaryContainerLight, + onSurfaceVariant = StoreColors.SubtitleTextLight, +) + +private val StoreDarkColorScheme = darkColorScheme( + primary = StoreColors.PrimaryDark, + onPrimary = Color(0xFF102819), + primaryContainer = StoreColors.SecondaryContainerDark, + onPrimaryContainer = StoreColors.TitleTextDark, + secondary = StoreColors.RoomFrameBgDark, + onSecondary = Color.White, + secondaryContainer = StoreColors.SecondaryContainerDark, + onSecondaryContainer = StoreColors.TitleTextDark, + background = StoreColors.BackgroundDark, + onBackground = StoreColors.TitleTextDark, + surface = StoreColors.SurfaceCardDark, + onSurface = StoreColors.TitleTextDark, + surfaceVariant = StoreColors.SecondaryContainerDark, + onSurfaceVariant = StoreColors.SubtitleTextDark, +) + +val StoreTypography = Typography( + displayLarge = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 36.sp, + fontWeight = FontWeight.Bold, + ), + displayMedium = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + ), + displaySmall = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + ), + headlineLarge = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold, + ), + headlineMedium = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 26.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.5).sp, + ), + headlineSmall = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ), + titleLarge = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 21.sp, + fontWeight = FontWeight.Bold, + ), + titleMedium = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ), + titleSmall = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ), + bodyLarge = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + ), + bodyMedium = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ), + bodySmall = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + ), + labelLarge = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + labelMedium = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ), + labelSmall = TextStyle( + fontFamily = MadimiOneFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ), +) + +@Composable +fun StoreTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (darkTheme) StoreDarkColorScheme else StoreLightColorScheme + val palette = if (darkTheme) DarkStorePalette else LightStorePalette + + CompositionLocalProvider(LocalStorePalette provides palette) { + MaterialTheme( + colorScheme = colorScheme, + typography = StoreTypography, + content = content, + ) + } +} + /** * Companion Store activity for the Remote Compose [AndroidPetWidget]. * Displays each active widget's ID and collected coin balance. @@ -55,75 +314,120 @@ import com.example.platform.ui.appwidgets.R class StoreActivity : ComponentActivity() { private var activeStates by mutableStateOf>(emptyList()) + private var selectedWidgetId by mutableStateOf(null) + + private fun extractWidgetIdFromIntent(intent: Intent?): Int? { + if (intent == null) return null + val standardId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) + if (standardId != AppWidgetManager.INVALID_APPWIDGET_ID) { + return standardId + } + val fallbackId = intent.getIntExtra( + "appWidgetId", + AppWidgetManager.INVALID_APPWIDGET_ID, + ) + return if (fallbackId != AppWidgetManager.INVALID_APPWIDGET_ID) { + fallbackId + } else { + null + } + } override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + activeStates = BuddyPreferences.getActiveBuddyStates(this) + selectedWidgetId = extractWidgetIdFromIntent(intent) + setContent { - MaterialTheme { - StoreScreen(activeStates = activeStates) + StoreTheme { + StoreScreen( + activeStates = activeStates, + selectedWidgetId = selectedWidgetId, + onSelectBuddy = { widgetId -> + selectedWidgetId = widgetId + }, + ) } } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + val incomingWidgetId = extractWidgetIdFromIntent(intent) + if (incomingWidgetId != null) { + selectedWidgetId = incomingWidgetId + } + activeStates = BuddyPreferences.getActiveBuddyStates(this) + } + override fun onResume() { super.onResume() activeStates = BuddyPreferences.getActiveBuddyStates(this) } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun StoreScreen(activeStates: List) { +fun StoreScreen( + activeStates: List, + selectedWidgetId: Int? = null, + onSelectBuddy: (Int?) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val palette = LocalStorePalette.current + val effectiveSelectedId = selectedWidgetId?.takeIf { id -> activeStates.any { it.widgetId == id } } + val displayedBuddies = if (effectiveSelectedId == null) { + activeStates + } else { + activeStates.filter { it.widgetId == effectiveSelectedId } + } + val totalCoins = activeStates.sumOf { it.coins } + Scaffold( - topBar = { - TopAppBar( - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Image( - painter = painterResource(id = R.drawable.shop_button), - contentDescription = null, - modifier = Modifier.size(28.dp), - ) - Text(text = "Android Pet Store") - } - }, - ) - }, + modifier = modifier.fillMaxSize(), + containerColor = palette.background, ) { innerPadding -> - LazyColumn( + Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding( + top = innerPadding.calculateTopPadding() + 16.dp, + start = 16.dp, + end = 16.dp, + ) + .consumeWindowInsets(innerPadding), ) { + StoreTopBar(totalCoins = totalCoins) + + if (activeStates.isNotEmpty()) { + BuddySelectorBar( + buddies = activeStates, + selectedWidgetId = effectiveSelectedId, + onSelectBuddy = onSelectBuddy, + modifier = Modifier.padding(bottom = 12.dp), + ) + } + if (activeStates.isEmpty()) { - item { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "No active Android Pet widgets", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = "Pin an Android Pet widget to your home screen and tap the pet to collect coins.", - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } + StoreEmptyStateCard() } else { - items(activeStates, key = { it.widgetId }) { state -> - WidgetCoinCard(state = state) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag("store_buddy_list"), + contentPadding = PaddingValues( + bottom = innerPadding.calculateBottomPadding() + 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + items(displayedBuddies, key = { it.widgetId }) { state -> + WidgetCoinCard(state = state) + } } } } @@ -131,34 +435,232 @@ fun StoreScreen(activeStates: List) { } @Composable -private fun WidgetCoinCard(state: WidgetBuddyState) { - Card(modifier = Modifier.fillMaxWidth()) { +fun StoreTopBar( + totalCoins: Int, + modifier: Modifier = Modifier, +) { + val palette = LocalStorePalette.current + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .testTag("store_top_bar"), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + Surface( + shape = CircleShape, + color = palette.primary.copy(alpha = 0.14f), + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(id = R.drawable.rounded_storefront), + contentDescription = null, + tint = palette.primary, + modifier = Modifier.size(24.dp), + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "Pet Buddy Store", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.ExtraBold, + color = palette.titleText, + ) + Text( + text = "Furnish & customize your buddy's room", + style = MaterialTheme.typography.bodyMedium, + color = palette.subtitleText, + ) + } + } + + Surface( + shape = RoundedCornerShape(50), + color = palette.coinBadgeBg, + border = BorderStroke(1.dp, palette.coinBadgeText.copy(alpha = 0.25f)), + modifier = Modifier.testTag("store_header_coin_badge"), ) { Text( - text = "Widget ID: ${state.widgetId}", - style = MaterialTheme.typography.titleMedium, + text = "🪙 $totalCoins", + style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, + color = palette.coinBadgeText, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), ) + } + } +} + +@Composable +fun BuddySelectorBar( + buddies: List, + selectedWidgetId: Int?, + onSelectBuddy: (Int?) -> Unit, + modifier: Modifier = Modifier, +) { + val palette = LocalStorePalette.current + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .testTag("buddy_selector_bar"), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val allSelected = selectedWidgetId == null + Surface( + shape = RoundedCornerShape(50), + color = if (allSelected) palette.primary else palette.surfaceCard, + border = BorderStroke( + width = 1.dp, + color = if (allSelected) palette.primary else palette.subtitleText.copy(alpha = 0.3f), + ), + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable { onSelectBuddy(null) } + .semantics { selected = allSelected } + .testTag("buddy_selector_all"), + ) { + Text( + text = "All Buddies", + style = MaterialTheme.typography.labelLarge, + color = if (allSelected) Color.White else palette.titleText, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + + buddies.forEach { buddy -> + val isSelected = selectedWidgetId == buddy.widgetId + Surface( + shape = RoundedCornerShape(50), + color = if (isSelected) palette.primary else palette.surfaceCard, + border = BorderStroke( + width = 1.dp, + color = if (isSelected) palette.primary else palette.subtitleText.copy(alpha = 0.3f), + ), + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable { onSelectBuddy(buddy.widgetId) } + .semantics { selected = isSelected } + .testTag("buddy_selector_chip_${buddy.widgetId}"), + ) { + Text( + text = buildAnnotatedString { + withStyle( + SpanStyle( + color = if (isSelected) Color.White else palette.titleText, + ), + ) { + append("Android Buddy ") + } + withStyle( + SpanStyle( + color = if (isSelected) { + Color.White.copy(alpha = 0.85f) + } else { + palette.subtitleText + }, + ), + ) { + append("#${buddy.widgetId}") + } + }, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + } +} + +@Composable +fun StoreEmptyStateCard( + modifier: Modifier = Modifier, +) { + val palette = LocalStorePalette.current + ElevatedCard( + modifier = modifier + .fillMaxWidth() + .testTag("store_empty_state_card"), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.elevatedCardColors(containerColor = palette.surfaceCard), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "🐾", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "No active pet widgets found. Add a Pet Buddy widget to access the store!", + style = MaterialTheme.typography.bodyLarge, + color = palette.titleText, + ) + } + } +} + +@Composable +fun WidgetCoinCard( + state: WidgetBuddyState, + modifier: Modifier = Modifier, +) { + val palette = LocalStorePalette.current + ElevatedCard( + modifier = modifier + .fillMaxWidth() + .testTag("store_buddy_card_${state.widgetId}"), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.elevatedCardColors(containerColor = palette.surfaceCard), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 6.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + ) { Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Image( - painter = painterResource(id = R.drawable.claim_credit_button), - contentDescription = null, - modifier = Modifier.size(28.dp), - ) Text( - text = "Coins: ${state.coins}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + text = "Android Buddy: #${state.widgetId}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = palette.titleText, + modifier = Modifier.weight(1f), ) + + Surface( + shape = RoundedCornerShape(50), + color = palette.coinBadgeBg, + border = BorderStroke(1.dp, palette.coinBadgeText.copy(alpha = 0.25f)), + ) { + Text( + text = "${state.coins} credits 🪙", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = palette.coinBadgeText, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 6.dp) + .testTag("text_store_credits_${state.widgetId}"), + ) + } } } } diff --git a/samples/user-interface/appwidgets/src/main/res/drawable/rounded_storefront.xml b/samples/user-interface/appwidgets/src/main/res/drawable/rounded_storefront.xml new file mode 100644 index 00000000..10f70f0e --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/res/drawable/rounded_storefront.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/samples/user-interface/appwidgets/src/main/res/font/madimi_one_regular.ttf b/samples/user-interface/appwidgets/src/main/res/font/madimi_one_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..63b22aace3e70f15cd272d34fad7085899e0da48 GIT binary patch literal 98100 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 z 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 Date: Tue, 13 Oct 2026 17:29:45 +0000 Subject: [PATCH 12/21] Move pet onTouchDown directly onto RemoteColumn and remove inner RemoteBox --- .../remote_compose/AndroidPetWidget.kt | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt index 2ccdd759..c6fe571d 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/AndroidPetWidget.kt @@ -65,25 +65,18 @@ class AndroidPetWidget : RemoteComposeWidget() { modifier = RemoteModifier.fillMaxSize().background(Color.White.rc), contentAlignment = RemoteAlignment.Center ) { - // Declared before the action buttons so the buttons sit on top of the pet touch area. - RemoteBox( - modifier = RemoteModifier - .fillMaxSize() - .onTouchDown(valueChange(petTaps, nextPetTaps)), - contentAlignment = RemoteAlignment.Center + RemoteColumn( + modifier = RemoteModifier.onTouchDown(valueChange(petTaps, nextPetTaps)), + horizontalAlignment = RemoteAlignment.CenterHorizontally ) { - RemoteColumn( - horizontalAlignment = RemoteAlignment.CenterHorizontally - ) { - RemoteText( - text = "Android Pet".rs, - color = Color.Black.rc - ) - RemoteText( - text = "Coins: $coins".rs, - color = Color.DarkGray.rc - ) - } + RemoteText( + text = "Android Pet".rs, + color = Color.Black.rc + ) + RemoteText( + text = "Coins: $coins".rs, + color = Color.DarkGray.rc + ) } WidgetActionBar( From 87106b2c0e1b5ddcc0017742e8464b62e891c539 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 17:39:01 +0000 Subject: [PATCH 13/21] Use RemoteImage instead of RemoteBox and RemoteCanvas in ClaimCreditButton and StoreButton --- .../remote_compose/ClaimCreditButton.kt | 18 ++++++------------ .../appwidgets/remote_compose/StoreButton.kt | 18 ++++++------------ 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt index 304daed8..267e00c2 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt @@ -18,17 +18,16 @@ package com.example.platform.ui.appwidgets.remote_compose import android.annotation.SuppressLint import android.content.Context -import androidx.compose.remote.creation.compose.layout.RemoteBox -import androidx.compose.remote.creation.compose.layout.RemoteCanvas import androidx.compose.remote.creation.compose.layout.RemoteComposable +import androidx.compose.remote.creation.compose.layout.RemoteImage import androidx.compose.remote.creation.compose.modifier.RemoteModifier -import androidx.compose.remote.creation.compose.modifier.fillMaxSize import androidx.compose.remote.creation.compose.modifier.padding import androidx.compose.remote.creation.compose.modifier.size import androidx.compose.remote.creation.compose.modifier.visibility import androidx.compose.remote.creation.compose.state.RemoteInt import androidx.compose.remote.creation.compose.state.rb import androidx.compose.remote.creation.compose.state.rdp +import androidx.compose.remote.creation.compose.state.rs import androidx.compose.remote.creation.compose.widgets.onClick import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -46,7 +45,9 @@ fun ClaimCreditButton( WidgetAssetProvider.claimCreditBitmap(context) } - RemoteBox( + RemoteImage( + remoteBitmap = bitmap.asImageBitmap().rb, + contentDescription = "Claim Credit".rs, modifier = RemoteModifier .padding(end = 68.rdp, bottom = 12.rdp) .size(48.rdp) @@ -54,12 +55,5 @@ fun ClaimCreditButton( .onClick { BuddyPreferences.addCoinsForWidget(context, widgetId) }, - ) { - RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { - drawScaledBitmap( - image = bitmap.asImageBitmap().rb, - contentDescription = "Claim Credit", - ) - } - } + ) } diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt index 6fd6b512..8ebf3f05 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt @@ -22,15 +22,14 @@ import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent import android.os.Build -import androidx.compose.remote.creation.compose.layout.RemoteBox -import androidx.compose.remote.creation.compose.layout.RemoteCanvas import androidx.compose.remote.creation.compose.layout.RemoteComposable +import androidx.compose.remote.creation.compose.layout.RemoteImage import androidx.compose.remote.creation.compose.modifier.RemoteModifier -import androidx.compose.remote.creation.compose.modifier.fillMaxSize import androidx.compose.remote.creation.compose.modifier.padding import androidx.compose.remote.creation.compose.modifier.size import androidx.compose.remote.creation.compose.state.rb import androidx.compose.remote.creation.compose.state.rdp +import androidx.compose.remote.creation.compose.state.rs import androidx.compose.remote.creation.compose.widgets.onClick import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -47,21 +46,16 @@ fun StoreButton( WidgetAssetProvider.storeBitmap(context) } - RemoteBox( + RemoteImage( + remoteBitmap = bitmap.asImageBitmap().rb, + contentDescription = "Store".rs, modifier = RemoteModifier .padding(12.rdp) .size(48.rdp) .onClick { launchStoreActivity(context, widgetId) }, - ) { - RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) { - drawScaledBitmap( - image = bitmap.asImageBitmap().rb, - contentDescription = "Store", - ) - } - } + ) } internal fun launchStoreActivity(context: Context, widgetId: Int) { From d8c67c32b1e7a34a0caed99cbcf4b4dd0ec65dd0 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 17:47:29 +0000 Subject: [PATCH 14/21] Simplify StoreActivity with dynamic colors, trimmed typography, and reusable pill/chip composables --- .../remote_compose/StoreActivity.kt | 455 ++++-------------- 1 file changed, 101 insertions(+), 354 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt index 30e3dbdf..c71c3761 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -18,6 +18,7 @@ package com.example.platform.ui.appwidgets.remote_compose import android.appwidget.AppWidgetManager import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -52,34 +53,28 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.platform.ui.appwidgets.R -val MadimiOneFontFamily = FontFamily( +private val MadimiOneFontFamily = FontFamily( Font(R.font.madimi_one_regular, FontWeight.Normal), Font(R.font.madimi_one_regular, FontWeight.Medium), Font(R.font.madimi_one_regular, FontWeight.SemiBold), @@ -87,177 +82,18 @@ val MadimiOneFontFamily = FontFamily( Font(R.font.madimi_one_regular, FontWeight.ExtraBold), ) -/** - * Brand color tokens for the modern, playful Pet Buddy Store aesthetic. - */ -object StoreColors { - // Light Mode Tokens - val BackgroundLight = Color(0xFFF5F7F0) - val SurfaceCardLight = Color(0xFFFFFFFF) - val RoomFrameBgLight = Color(0xFF3B4D5B) - val PrimaryLight = Color(0xFF2E6B4E) - val SecondaryContainerLight = Color(0xFFE8F3EC) - val OwnedContainerLight = Color(0xFFEAF7EE) - val OwnedBorderLight = Color(0xFF4CAF50) - val CoinBadgeBgLight = Color(0xFFFFF3CD) - val CoinBadgeTextLight = Color(0xFF7A5200) - val DiscoAccentBgLight = Color(0xFF4A154B) - val DiscoAccentBorder = Color(0xFFFFD54F) - val DisabledContainerLight = Color(0xFFF0F1EE) - val TitleTextLight = Color(0xFF263329) - val SubtitleTextLight = Color(0xFF526056) - - // Dark Mode Tokens - val BackgroundDark = Color(0xFF141815) - val SurfaceCardDark = Color(0xFF1E2420) - val RoomFrameBgDark = Color(0xFF263540) - val PrimaryDark = Color(0xFF81C784) - val SecondaryContainerDark = Color(0xFF25382E) - val OwnedContainerDark = Color(0xFF1F3528) - val OwnedBorderDark = Color(0xFF66BB6A) - val CoinBadgeBgDark = Color(0xFF3D3112) - val CoinBadgeTextDark = Color(0xFFFFD54F) - val DiscoAccentBgDark = Color(0xFF5C1D5E) - val DisabledContainerDark = Color(0xFF252826) - val TitleTextDark = Color(0xFFE8F0EA) - val SubtitleTextDark = Color(0xFFA6B5AA) -} - -@Immutable -data class StorePalette( - val background: Color, - val surfaceCard: Color, - val roomFrameBg: Color, - val primary: Color, - val secondaryContainer: Color, - val ownedContainer: Color, - val ownedBorder: Color, - val coinBadgeBg: Color, - val coinBadgeText: Color, - val discoAccentBg: Color, - val discoAccentBorder: Color, - val disabledContainer: Color, - val titleText: Color, - val subtitleText: Color, -) - -private val LightStorePalette = StorePalette( - background = StoreColors.BackgroundLight, - surfaceCard = StoreColors.SurfaceCardLight, - roomFrameBg = StoreColors.RoomFrameBgLight, - primary = StoreColors.PrimaryLight, - secondaryContainer = StoreColors.SecondaryContainerLight, - ownedContainer = StoreColors.OwnedContainerLight, - ownedBorder = StoreColors.OwnedBorderLight, - coinBadgeBg = StoreColors.CoinBadgeBgLight, - coinBadgeText = StoreColors.CoinBadgeTextLight, - discoAccentBg = StoreColors.DiscoAccentBgLight, - discoAccentBorder = StoreColors.DiscoAccentBorder, - disabledContainer = StoreColors.DisabledContainerLight, - titleText = StoreColors.TitleTextLight, - subtitleText = StoreColors.SubtitleTextLight, -) - -private val DarkStorePalette = StorePalette( - background = StoreColors.BackgroundDark, - surfaceCard = StoreColors.SurfaceCardDark, - roomFrameBg = StoreColors.RoomFrameBgDark, - primary = StoreColors.PrimaryDark, - secondaryContainer = StoreColors.SecondaryContainerDark, - ownedContainer = StoreColors.OwnedContainerDark, - ownedBorder = StoreColors.OwnedBorderDark, - coinBadgeBg = StoreColors.CoinBadgeBgDark, - coinBadgeText = StoreColors.CoinBadgeTextDark, - discoAccentBg = StoreColors.DiscoAccentBgDark, - discoAccentBorder = StoreColors.DiscoAccentBorder, - disabledContainer = StoreColors.DisabledContainerDark, - titleText = StoreColors.TitleTextDark, - subtitleText = StoreColors.SubtitleTextDark, -) - -val LocalStorePalette = staticCompositionLocalOf { LightStorePalette } - -private val StoreLightColorScheme = lightColorScheme( - primary = StoreColors.PrimaryLight, - onPrimary = Color.White, - primaryContainer = StoreColors.SecondaryContainerLight, - onPrimaryContainer = StoreColors.TitleTextLight, - secondary = StoreColors.RoomFrameBgLight, - onSecondary = Color.White, - secondaryContainer = StoreColors.SecondaryContainerLight, - onSecondaryContainer = StoreColors.TitleTextLight, - background = StoreColors.BackgroundLight, - onBackground = StoreColors.TitleTextLight, - surface = StoreColors.SurfaceCardLight, - onSurface = StoreColors.TitleTextLight, - surfaceVariant = StoreColors.SecondaryContainerLight, - onSurfaceVariant = StoreColors.SubtitleTextLight, -) - -private val StoreDarkColorScheme = darkColorScheme( - primary = StoreColors.PrimaryDark, - onPrimary = Color(0xFF102819), - primaryContainer = StoreColors.SecondaryContainerDark, - onPrimaryContainer = StoreColors.TitleTextDark, - secondary = StoreColors.RoomFrameBgDark, - onSecondary = Color.White, - secondaryContainer = StoreColors.SecondaryContainerDark, - onSecondaryContainer = StoreColors.TitleTextDark, - background = StoreColors.BackgroundDark, - onBackground = StoreColors.TitleTextDark, - surface = StoreColors.SurfaceCardDark, - onSurface = StoreColors.TitleTextDark, - surfaceVariant = StoreColors.SecondaryContainerDark, - onSurfaceVariant = StoreColors.SubtitleTextDark, -) - -val StoreTypography = Typography( - displayLarge = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 36.sp, - fontWeight = FontWeight.Bold, - ), - displayMedium = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - ), - displaySmall = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - ), - headlineLarge = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 28.sp, - fontWeight = FontWeight.ExtraBold, - ), +private val StoreTypography = Typography( headlineMedium = TextStyle( fontFamily = MadimiOneFontFamily, fontSize = 26.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.5).sp, ), - headlineSmall = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - ), titleLarge = TextStyle( fontFamily = MadimiOneFontFamily, fontSize = 21.sp, fontWeight = FontWeight.Bold, ), - titleMedium = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - ), - titleSmall = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ), bodyLarge = TextStyle( fontFamily = MadimiOneFontFamily, fontSize = 15.sp, @@ -268,26 +104,11 @@ val StoreTypography = Typography( fontSize = 14.sp, fontWeight = FontWeight.Medium, ), - bodySmall = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Normal, - ), labelLarge = TextStyle( fontFamily = MadimiOneFontFamily, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, ), - labelMedium = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - ), - labelSmall = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - ), ) @Composable @@ -295,16 +116,20 @@ fun StoreTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { - val colorScheme = if (darkTheme) StoreDarkColorScheme else StoreLightColorScheme - val palette = if (darkTheme) DarkStorePalette else LightStorePalette - - CompositionLocalProvider(LocalStorePalette provides palette) { - MaterialTheme( - colorScheme = colorScheme, - typography = StoreTypography, - content = content, - ) + val context = LocalContext.current + val colorScheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> darkColorScheme() + else -> lightColorScheme() } + + MaterialTheme( + colorScheme = colorScheme, + typography = StoreTypography, + content = content, + ) } /** @@ -316,25 +141,10 @@ class StoreActivity : ComponentActivity() { private var activeStates by mutableStateOf>(emptyList()) private var selectedWidgetId by mutableStateOf(null) - private fun extractWidgetIdFromIntent(intent: Intent?): Int? { - if (intent == null) return null - val standardId = intent.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ) - if (standardId != AppWidgetManager.INVALID_APPWIDGET_ID) { - return standardId - } - val fallbackId = intent.getIntExtra( - "appWidgetId", - AppWidgetManager.INVALID_APPWIDGET_ID, - ) - return if (fallbackId != AppWidgetManager.INVALID_APPWIDGET_ID) { - fallbackId - } else { - null - } - } + private fun extractWidgetIdFromIntent(intent: Intent?): Int? = + intent + ?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + ?.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID } override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() @@ -348,9 +158,7 @@ class StoreActivity : ComponentActivity() { StoreScreen( activeStates = activeStates, selectedWidgetId = selectedWidgetId, - onSelectBuddy = { widgetId -> - selectedWidgetId = widgetId - }, + onSelectBuddy = { widgetId -> selectedWidgetId = widgetId }, ) } } @@ -359,10 +167,7 @@ class StoreActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - val incomingWidgetId = extractWidgetIdFromIntent(intent) - if (incomingWidgetId != null) { - selectedWidgetId = incomingWidgetId - } + extractWidgetIdFromIntent(intent)?.let { selectedWidgetId = it } activeStates = BuddyPreferences.getActiveBuddyStates(this) } @@ -379,7 +184,6 @@ fun StoreScreen( onSelectBuddy: (Int?) -> Unit = {}, modifier: Modifier = Modifier, ) { - val palette = LocalStorePalette.current val effectiveSelectedId = selectedWidgetId?.takeIf { id -> activeStates.any { it.widgetId == id } } val displayedBuddies = if (effectiveSelectedId == null) { activeStates @@ -388,10 +192,7 @@ fun StoreScreen( } val totalCoins = activeStates.sumOf { it.coins } - Scaffold( - modifier = modifier.fillMaxSize(), - containerColor = palette.background, - ) { innerPadding -> + Scaffold(modifier = modifier.fillMaxSize()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -417,9 +218,7 @@ fun StoreScreen( StoreEmptyStateCard() } else { LazyColumn( - modifier = Modifier - .fillMaxSize() - .testTag("store_buddy_list"), + modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( bottom = innerPadding.calculateBottomPadding() + 16.dp, ), @@ -439,12 +238,10 @@ fun StoreTopBar( totalCoins: Int, modifier: Modifier = Modifier, ) { - val palette = LocalStorePalette.current Row( modifier = modifier .fillMaxWidth() - .padding(bottom = 16.dp) - .testTag("store_top_bar"), + .padding(bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -454,14 +251,14 @@ fun StoreTopBar( ) { Surface( shape = CircleShape, - color = palette.primary.copy(alpha = 0.14f), + color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.size(44.dp), ) { Box(contentAlignment = Alignment.Center) { Icon( painter = painterResource(id = R.drawable.rounded_storefront), contentDescription = null, - tint = palette.primary, + tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(24.dp), ) } @@ -471,31 +268,16 @@ fun StoreTopBar( Text( text = "Pet Buddy Store", style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.ExtraBold, - color = palette.titleText, ) Text( text = "Furnish & customize your buddy's room", style = MaterialTheme.typography.bodyMedium, - color = palette.subtitleText, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - Surface( - shape = RoundedCornerShape(50), - color = palette.coinBadgeBg, - border = BorderStroke(1.dp, palette.coinBadgeText.copy(alpha = 0.25f)), - modifier = Modifier.testTag("store_header_coin_badge"), - ) { - Text( - text = "🪙 $totalCoins", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = palette.coinBadgeText, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - ) - } + CreditPill(text = "🪙 $totalCoins") } } @@ -506,78 +288,52 @@ fun BuddySelectorBar( onSelectBuddy: (Int?) -> Unit, modifier: Modifier = Modifier, ) { - val palette = LocalStorePalette.current Row( modifier = modifier .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .testTag("buddy_selector_bar"), + .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - val allSelected = selectedWidgetId == null - Surface( - shape = RoundedCornerShape(50), - color = if (allSelected) palette.primary else palette.surfaceCard, - border = BorderStroke( - width = 1.dp, - color = if (allSelected) palette.primary else palette.subtitleText.copy(alpha = 0.3f), - ), - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { onSelectBuddy(null) } - .semantics { selected = allSelected } - .testTag("buddy_selector_all"), - ) { - Text( - text = "All Buddies", - style = MaterialTheme.typography.labelLarge, - color = if (allSelected) Color.White else palette.titleText, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + BuddyFilterChip( + text = "All Buddies", + selected = selectedWidgetId == null, + onClick = { onSelectBuddy(null) }, + ) + buddies.forEach { buddy -> + BuddyFilterChip( + text = "Android Buddy #${buddy.widgetId}", + selected = selectedWidgetId == buddy.widgetId, + onClick = { onSelectBuddy(buddy.widgetId) }, ) } + } +} - buddies.forEach { buddy -> - val isSelected = selectedWidgetId == buddy.widgetId - Surface( - shape = RoundedCornerShape(50), - color = if (isSelected) palette.primary else palette.surfaceCard, - border = BorderStroke( - width = 1.dp, - color = if (isSelected) palette.primary else palette.subtitleText.copy(alpha = 0.3f), - ), - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { onSelectBuddy(buddy.widgetId) } - .semantics { selected = isSelected } - .testTag("buddy_selector_chip_${buddy.widgetId}"), - ) { - Text( - text = buildAnnotatedString { - withStyle( - SpanStyle( - color = if (isSelected) Color.White else palette.titleText, - ), - ) { - append("Android Buddy ") - } - withStyle( - SpanStyle( - color = if (isSelected) { - Color.White.copy(alpha = 0.85f) - } else { - palette.subtitleText - }, - ), - ) { - append("#${buddy.widgetId}") - } - }, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - } - } +@Composable +private fun BuddyFilterChip( + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + Surface( + shape = RoundedCornerShape(50), + color = if (selected) colorScheme.primary else colorScheme.surfaceContainerLow, + border = BorderStroke( + width = 1.dp, + color = if (selected) colorScheme.primary else colorScheme.outlineVariant, + ), + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable(onClick = onClick), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = if (selected) colorScheme.onPrimary else colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) } } @@ -585,13 +341,9 @@ fun BuddySelectorBar( fun StoreEmptyStateCard( modifier: Modifier = Modifier, ) { - val palette = LocalStorePalette.current ElevatedCard( - modifier = modifier - .fillMaxWidth() - .testTag("store_empty_state_card"), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(24.dp), - colors = CardDefaults.elevatedCardColors(containerColor = palette.surfaceCard), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 4.dp), ) { Column( @@ -608,7 +360,6 @@ fun StoreEmptyStateCard( Text( text = "No active pet widgets found. Add a Pet Buddy widget to access the store!", style = MaterialTheme.typography.bodyLarge, - color = palette.titleText, ) } } @@ -619,49 +370,45 @@ fun WidgetCoinCard( state: WidgetBuddyState, modifier: Modifier = Modifier, ) { - val palette = LocalStorePalette.current ElevatedCard( - modifier = modifier - .fillMaxWidth() - .testTag("store_buddy_card_${state.widgetId}"), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(28.dp), - colors = CardDefaults.elevatedCardColors(containerColor = palette.surfaceCard), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 6.dp), ) { - Column( + Row( modifier = Modifier .fillMaxWidth() .padding(18.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Android Buddy: #${state.widgetId}", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = palette.titleText, - modifier = Modifier.weight(1f), - ) - - Surface( - shape = RoundedCornerShape(50), - color = palette.coinBadgeBg, - border = BorderStroke(1.dp, palette.coinBadgeText.copy(alpha = 0.25f)), - ) { - Text( - text = "${state.coins} credits 🪙", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = palette.coinBadgeText, - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 6.dp) - .testTag("text_store_credits_${state.widgetId}"), - ) - } - } + Text( + text = "Android Buddy: #${state.widgetId}", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + CreditPill(text = "${state.coins} credits 🪙") } } } + +@Composable +private fun CreditPill(text: String) { + val isDark = isSystemInDarkTheme() + val badgeBg = if (isDark) Color(0xFF3D3112) else Color(0xFFFFF3CD) + val badgeText = if (isDark) Color(0xFFFFD54F) else Color(0xFF7A5200) + + Surface( + shape = RoundedCornerShape(50), + color = badgeBg, + border = BorderStroke(1.dp, badgeText.copy(alpha = 0.25f)), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = badgeText, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } +} From d32f388ddd7dfc26fe87f42b5c7dbb40a3dbb9c2 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 17:57:37 +0000 Subject: [PATCH 15/21] Clean up StoreActivity by removing StoreEmptyStateCard, unused modifiers, and redundant loads --- .../remote_compose/StoreActivity.kt | 106 ++++-------------- 1 file changed, 23 insertions(+), 83 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt index c71c3761..41e48524 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -24,7 +24,6 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -62,7 +61,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -74,13 +72,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.platform.ui.appwidgets.R -private val MadimiOneFontFamily = FontFamily( - Font(R.font.madimi_one_regular, FontWeight.Normal), - Font(R.font.madimi_one_regular, FontWeight.Medium), - Font(R.font.madimi_one_regular, FontWeight.SemiBold), - Font(R.font.madimi_one_regular, FontWeight.Bold), - Font(R.font.madimi_one_regular, FontWeight.ExtraBold), -) +private val MadimiOneFontFamily = FontFamily(Font(R.font.madimi_one_regular)) private val StoreTypography = Typography( headlineMedium = TextStyle( @@ -94,11 +86,6 @@ private val StoreTypography = Typography( fontSize = 21.sp, fontWeight = FontWeight.Bold, ), - bodyLarge = TextStyle( - fontFamily = MadimiOneFontFamily, - fontSize = 15.sp, - fontWeight = FontWeight.Normal, - ), bodyMedium = TextStyle( fontFamily = MadimiOneFontFamily, fontSize = 14.sp, @@ -149,8 +136,6 @@ class StoreActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - - activeStates = BuddyPreferences.getActiveBuddyStates(this) selectedWidgetId = extractWidgetIdFromIntent(intent) setContent { @@ -168,7 +153,6 @@ class StoreActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) extractWidgetIdFromIntent(intent)?.let { selectedWidgetId = it } - activeStates = BuddyPreferences.getActiveBuddyStates(this) } override fun onResume() { @@ -182,7 +166,6 @@ fun StoreScreen( activeStates: List, selectedWidgetId: Int? = null, onSelectBuddy: (Int?) -> Unit = {}, - modifier: Modifier = Modifier, ) { val effectiveSelectedId = selectedWidgetId?.takeIf { id -> activeStates.any { it.widgetId == id } } val displayedBuddies = if (effectiveSelectedId == null) { @@ -192,7 +175,7 @@ fun StoreScreen( } val totalCoins = activeStates.sumOf { it.coins } - Scaffold(modifier = modifier.fillMaxSize()) { innerPadding -> + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -205,28 +188,21 @@ fun StoreScreen( ) { StoreTopBar(totalCoins = totalCoins) - if (activeStates.isNotEmpty()) { - BuddySelectorBar( - buddies = activeStates, - selectedWidgetId = effectiveSelectedId, - onSelectBuddy = onSelectBuddy, - modifier = Modifier.padding(bottom = 12.dp), - ) - } + BuddySelectorBar( + buddies = activeStates, + selectedWidgetId = effectiveSelectedId, + onSelectBuddy = onSelectBuddy, + ) - if (activeStates.isEmpty()) { - StoreEmptyStateCard() - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - bottom = innerPadding.calculateBottomPadding() + 16.dp, - ), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - items(displayedBuddies, key = { it.widgetId }) { state -> - WidgetCoinCard(state = state) - } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + bottom = innerPadding.calculateBottomPadding() + 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + items(displayedBuddies, key = { it.widgetId }) { state -> + WidgetCoinCard(state = state) } } } @@ -234,12 +210,9 @@ fun StoreScreen( } @Composable -fun StoreTopBar( - totalCoins: Int, - modifier: Modifier = Modifier, -) { +fun StoreTopBar(totalCoins: Int) { Row( - modifier = modifier + modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -286,11 +259,11 @@ fun BuddySelectorBar( buddies: List, selectedWidgetId: Int?, onSelectBuddy: (Int?) -> Unit, - modifier: Modifier = Modifier, ) { Row( - modifier = modifier + modifier = Modifier .fillMaxWidth() + .padding(bottom = 12.dp) .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -318,15 +291,13 @@ private fun BuddyFilterChip( ) { val colorScheme = MaterialTheme.colorScheme Surface( + onClick = onClick, shape = RoundedCornerShape(50), color = if (selected) colorScheme.primary else colorScheme.surfaceContainerLow, border = BorderStroke( width = 1.dp, color = if (selected) colorScheme.primary else colorScheme.outlineVariant, ), - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable(onClick = onClick), ) { Text( text = text, @@ -338,40 +309,9 @@ private fun BuddyFilterChip( } @Composable -fun StoreEmptyStateCard( - modifier: Modifier = Modifier, -) { - ElevatedCard( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = 4.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "🐾", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(bottom = 8.dp), - ) - Text( - text = "No active pet widgets found. Add a Pet Buddy widget to access the store!", - style = MaterialTheme.typography.bodyLarge, - ) - } - } -} - -@Composable -fun WidgetCoinCard( - state: WidgetBuddyState, - modifier: Modifier = Modifier, -) { +fun WidgetCoinCard(state: WidgetBuddyState) { ElevatedCard( - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(28.dp), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 6.dp), ) { From 60f02523bc4445e7258f4198879b14e2181eae3e Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 18:08:45 +0000 Subject: [PATCH 16/21] Flatten StoreActivity Compose UI hierarchy and use M3 FilterChip --- .../remote_compose/StoreActivity.kt | 131 ++++++------------ 1 file changed, 42 insertions(+), 89 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt index 41e48524..2244a6a1 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -23,21 +23,18 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -45,10 +42,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme @@ -179,12 +176,8 @@ fun StoreScreen( Column( modifier = Modifier .fillMaxSize() - .padding( - top = innerPadding.calculateTopPadding() + 16.dp, - start = 16.dp, - end = 16.dp, - ) - .consumeWindowInsets(innerPadding), + .padding(innerPadding) + .padding(top = 16.dp, start = 16.dp, end = 16.dp), ) { StoreTopBar(totalCoins = totalCoins) @@ -196,9 +189,7 @@ fun StoreScreen( LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - bottom = innerPadding.calculateBottomPadding() + 16.dp, - ), + contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { items(displayedBuddies, key = { it.widgetId }) { state -> @@ -215,41 +206,29 @@ fun StoreTopBar(totalCoins: Int) { modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f), - ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(44.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - painter = painterResource(id = R.drawable.rounded_storefront), - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(24.dp), - ) - } - } - Spacer(modifier = Modifier.width(12.dp)) - Column { - Text( - text = "Pet Buddy Store", - style = MaterialTheme.typography.headlineMedium, - ) - Text( - text = "Furnish & customize your buddy's room", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Icon( + painter = painterResource(id = R.drawable.rounded_storefront), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape) + .padding(10.dp) + .size(24.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Pet Buddy Store", + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = "Furnish & customize your buddy's room", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - CreditPill(text = "🪙 $totalCoins") } } @@ -268,46 +247,23 @@ fun BuddySelectorBar( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - BuddyFilterChip( - text = "All Buddies", + FilterChip( selected = selectedWidgetId == null, onClick = { onSelectBuddy(null) }, + label = { Text("All Buddies") }, + shape = CircleShape, ) buddies.forEach { buddy -> - BuddyFilterChip( - text = "Android Buddy #${buddy.widgetId}", + FilterChip( selected = selectedWidgetId == buddy.widgetId, onClick = { onSelectBuddy(buddy.widgetId) }, + label = { Text("Android Buddy #${buddy.widgetId}") }, + shape = CircleShape, ) } } } -@Composable -private fun BuddyFilterChip( - text: String, - selected: Boolean, - onClick: () -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - Surface( - onClick = onClick, - shape = RoundedCornerShape(50), - color = if (selected) colorScheme.primary else colorScheme.surfaceContainerLow, - border = BorderStroke( - width = 1.dp, - color = if (selected) colorScheme.primary else colorScheme.outlineVariant, - ), - ) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = if (selected) colorScheme.onPrimary else colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - } -} - @Composable fun WidgetCoinCard(state: WidgetBuddyState) { ElevatedCard( @@ -338,17 +294,14 @@ private fun CreditPill(text: String) { val badgeBg = if (isDark) Color(0xFF3D3112) else Color(0xFFFFF3CD) val badgeText = if (isDark) Color(0xFFFFD54F) else Color(0xFF7A5200) - Surface( - shape = RoundedCornerShape(50), - color = badgeBg, - border = BorderStroke(1.dp, badgeText.copy(alpha = 0.25f)), - ) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = badgeText, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - ) - } + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = badgeText, + modifier = Modifier + .background(badgeBg, CircleShape) + .border(1.dp, badgeText.copy(alpha = 0.25f), CircleShape) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) } From 209a7aefb413ffd0b2a30669abc7ea97629d2e50 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 18:40:02 +0000 Subject: [PATCH 17/21] Use LazyRow in BuddySelectorBar and simplify StoreTheme and modifiers --- .../remote_compose/StoreActivity.kt | 52 ++++++------------- 1 file changed, 15 insertions(+), 37 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt index 2244a6a1..c0b42b18 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -18,26 +18,23 @@ package com.example.platform.ui.appwidgets.remote_compose import android.appwidget.AppWidgetManager import android.content.Intent -import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CardDefaults @@ -48,10 +45,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.Typography -import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -101,16 +96,8 @@ fun StoreTheme( content: @Composable () -> Unit, ) { val context = LocalContext.current - val colorScheme = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> darkColorScheme() - else -> lightColorScheme() - } - MaterialTheme( - colorScheme = colorScheme, + colorScheme = if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context), typography = StoreTypography, content = content, ) @@ -172,10 +159,9 @@ fun StoreScreen( } val totalCoins = activeStates.sumOf { it.coins } - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Scaffold { innerPadding -> Column( modifier = Modifier - .fillMaxSize() .padding(innerPadding) .padding(top = 16.dp, start = 16.dp, end = 16.dp), ) { @@ -188,7 +174,6 @@ fun StoreScreen( ) LazyColumn( - modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { @@ -239,21 +224,19 @@ fun BuddySelectorBar( selectedWidgetId: Int?, onSelectBuddy: (Int?) -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp) - .horizontalScroll(rememberScrollState()), + LazyRow( + modifier = Modifier.padding(bottom = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, ) { - FilterChip( - selected = selectedWidgetId == null, - onClick = { onSelectBuddy(null) }, - label = { Text("All Buddies") }, - shape = CircleShape, - ) - buddies.forEach { buddy -> + item { + FilterChip( + selected = selectedWidgetId == null, + onClick = { onSelectBuddy(null) }, + label = { Text("All Buddies") }, + shape = CircleShape, + ) + } + items(buddies, key = { it.widgetId }) { buddy -> FilterChip( selected = selectedWidgetId == buddy.widgetId, onClick = { onSelectBuddy(buddy.widgetId) }, @@ -267,15 +250,11 @@ fun BuddySelectorBar( @Composable fun WidgetCoinCard(state: WidgetBuddyState) { ElevatedCard( - modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(28.dp), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 6.dp), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(18.dp), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding(18.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -297,7 +276,6 @@ private fun CreditPill(text: String) { Text( text = text, style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, color = badgeText, modifier = Modifier .background(badgeBg, CircleShape) From 0d7a46c15f978ed9a38166b501a72ce7b3b8095a Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 19:24:13 +0000 Subject: [PATCH 18/21] Use Scaffold topBar slot and Material 3 TopAppBar in StoreActivity --- .../remote_compose/StoreActivity.kt | 74 ++++++++++--------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt index c0b42b18..fd24e4aa 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -39,11 +38,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.material3.Typography import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -159,14 +160,14 @@ fun StoreScreen( } val totalCoins = activeStates.sumOf { it.coins } - Scaffold { innerPadding -> + Scaffold( + topBar = { StoreTopBar(totalCoins = totalCoins) }, + ) { innerPadding -> Column( modifier = Modifier .padding(innerPadding) - .padding(top = 16.dp, start = 16.dp, end = 16.dp), + .padding(top = 8.dp, start = 16.dp, end = 16.dp), ) { - StoreTopBar(totalCoins = totalCoins) - BuddySelectorBar( buddies = activeStates, selectedWidgetId = effectiveSelectedId, @@ -185,37 +186,42 @@ fun StoreScreen( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun StoreTopBar(totalCoins: Int) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_storefront), - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier - .background(MaterialTheme.colorScheme.primaryContainer, CircleShape) - .padding(10.dp) - .size(24.dp), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Pet Buddy Store", - style = MaterialTheme.typography.headlineMedium, + TopAppBar( + navigationIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_storefront), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier + .padding(start = 16.dp, end = 12.dp) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape) + .padding(10.dp) + .size(24.dp), ) - Text( - text = "Furnish & customize your buddy's room", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + }, + title = { + Column { + Text( + text = "Pet Buddy Store", + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = "Furnish & customize your buddy's room", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + actions = { + CreditPill( + text = "🪙 $totalCoins", + modifier = Modifier.padding(end = 16.dp), ) - } - CreditPill(text = "🪙 $totalCoins") - } + }, + ) } @Composable @@ -268,7 +274,7 @@ fun WidgetCoinCard(state: WidgetBuddyState) { } @Composable -private fun CreditPill(text: String) { +private fun CreditPill(text: String, modifier: Modifier = Modifier) { val isDark = isSystemInDarkTheme() val badgeBg = if (isDark) Color(0xFF3D3112) else Color(0xFFFFF3CD) val badgeText = if (isDark) Color(0xFFFFD54F) else Color(0xFF7A5200) @@ -277,7 +283,7 @@ private fun CreditPill(text: String) { text = text, style = MaterialTheme.typography.labelLarge, color = badgeText, - modifier = Modifier + modifier = modifier .background(badgeBg, CircleShape) .border(1.dp, badgeText.copy(alpha = 0.25f), CircleShape) .padding(horizontal = 12.dp, vertical = 6.dp), From 3442480d2a4f72897655fc0f744335151a6ec706 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 19:44:29 +0000 Subject: [PATCH 19/21] Remove WidgetAssetProvider and decode drawables directly with toBitmap() --- .../remote_compose/ClaimCreditButton.kt | 8 +-- .../appwidgets/remote_compose/StoreButton.kt | 8 +-- .../remote_compose/WidgetAssetProvider.kt | 57 ------------------- 3 files changed, 8 insertions(+), 65 deletions(-) delete mode 100644 samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt index 267e00c2..bf88905e 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt @@ -30,8 +30,10 @@ import androidx.compose.remote.creation.compose.state.rdp import androidx.compose.remote.creation.compose.state.rs import androidx.compose.remote.creation.compose.widgets.onClick import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import com.example.platform.ui.appwidgets.R @SuppressLint("RestrictedApiAndroidX", "RestrictedApi") @RemoteComposable @@ -41,9 +43,7 @@ fun ClaimCreditButton( widgetId: Int, isClaimVisible: RemoteInt, ) { - val bitmap = remember(context) { - WidgetAssetProvider.claimCreditBitmap(context) - } + val bitmap = ContextCompat.getDrawable(context, R.drawable.claim_credit_button)!!.toBitmap() RemoteImage( remoteBitmap = bitmap.asImageBitmap().rb, diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt index 8ebf3f05..c1563e29 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt @@ -32,8 +32,10 @@ import androidx.compose.remote.creation.compose.state.rdp import androidx.compose.remote.creation.compose.state.rs import androidx.compose.remote.creation.compose.widgets.onClick import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import com.example.platform.ui.appwidgets.R @SuppressLint("RestrictedApiAndroidX", "RestrictedApi") @RemoteComposable @@ -42,9 +44,7 @@ fun StoreButton( context: Context, widgetId: Int, ) { - val bitmap = remember(context) { - WidgetAssetProvider.storeBitmap(context) - } + val bitmap = ContextCompat.getDrawable(context, R.drawable.shop_button)!!.toBitmap() RemoteImage( remoteBitmap = bitmap.asImageBitmap().rb, diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt deleted file mode 100644 index 54753b5a..00000000 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/WidgetAssetProvider.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2026 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.platform.ui.appwidgets.remote_compose - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import androidx.annotation.DrawableRes -import androidx.core.content.ContextCompat -import com.example.platform.ui.appwidgets.R -import java.util.concurrent.ConcurrentHashMap - -/** - * Decodes and caches drawable resources into [Bitmap] instances for use in Remote Compose widgets. - */ -object WidgetAssetProvider { - private val bitmapCache = ConcurrentHashMap() - - fun claimCreditBitmap(context: Context): Bitmap = - decodeBitmap(context, R.drawable.claim_credit_button) - - fun storeBitmap(context: Context): Bitmap = - decodeBitmap(context, R.drawable.shop_button) - - fun decodeBitmap(context: Context, @DrawableRes resId: Int): Bitmap { - val appContext = context.applicationContext ?: context - return bitmapCache.getOrPut(resId) { - decodeDrawableToBitmap(appContext, resId) - } - } - - private fun decodeDrawableToBitmap(context: Context, @DrawableRes resId: Int): Bitmap { - val drawable = ContextCompat.getDrawable(context, resId) - ?: return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) - val width = drawable.intrinsicWidth.coerceAtLeast(1) - val height = drawable.intrinsicHeight.coerceAtLeast(1) - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - drawable.setBounds(0, 0, canvas.width, canvas.height) - drawable.draw(canvas) - return bitmap - } -} From 9077895c2a193814a3c70341a20be9647797bf30 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Tue, 13 Oct 2026 20:33:47 +0000 Subject: [PATCH 20/21] refactoring --- .../appwidgets/remote_compose/BuddyPreferences.kt | 13 +++++++------ .../ui/appwidgets/remote_compose/StoreActivity.kt | 11 ++++++++++- .../ui/appwidgets/remote_compose/StoreButton.kt | 8 ++++---- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt index 5ac9d078..ac638b0c 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/BuddyPreferences.kt @@ -20,6 +20,7 @@ import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit /** * Snapshot of an Android Pet widget instance's state. @@ -44,16 +45,16 @@ object BuddyPreferences { fun addCoinsForWidget(context: Context, widgetId: Int, amount: Int = 1): Int { val updated = (getCoinsForWidget(context, widgetId) + amount).coerceAtLeast(0) - prefs(context).edit() - .putInt("$KEY_COINS_PREFIX$widgetId", updated) - .apply() + prefs(context).edit { + putInt("$KEY_COINS_PREFIX$widgetId", updated) + } return updated } fun removeWidgetState(context: Context, widgetId: Int) { - prefs(context).edit() - .remove("$KEY_COINS_PREFIX$widgetId") - .apply() + prefs(context).edit { + remove("$KEY_COINS_PREFIX$widgetId") + } } fun getActiveBuddyStates(context: Context): List { diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt index fd24e4aa..60131fed 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreActivity.kt @@ -18,6 +18,7 @@ package com.example.platform.ui.appwidgets.remote_compose import android.appwidget.AppWidgetManager import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -46,8 +47,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -97,8 +100,14 @@ fun StoreTheme( content: @Composable () -> Unit, ) { val context = LocalContext.current + val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } else { + if (darkTheme) darkColorScheme() else lightColorScheme() + } + MaterialTheme( - colorScheme = if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context), + colorScheme = colorScheme, typography = StoreTypography, content = content, ) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt index c1563e29..f448c9b6 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt @@ -62,14 +62,14 @@ internal fun launchStoreActivity(context: Context, widgetId: Int) { val intent = Intent(context, StoreActivity::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_CLEAR_TOP or - Intent.FLAG_ACTIVITY_SINGLE_TOP + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { val options = ActivityOptions.makeBasic().apply { - if (Build.VERSION.SDK_INT >= 36) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { setPendingIntentCreatorBackgroundActivityStartMode( - ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS ) } else { @Suppress("DEPRECATION") From a2e12c67e7274d6d065bed7895de42c74fc15584 Mon Sep 17 00:00:00 2001 From: Ash Nohe Date: Wed, 14 Oct 2026 21:18:07 +0000 Subject: [PATCH 21/21] Address PR review feedback in README, StoreButton, and ClaimCreditButton --- samples/user-interface/appwidgets/README.md | 20 +++++++++++++++++-- .../remote_compose/ClaimCreditButton.kt | 7 +++++-- .../appwidgets/remote_compose/StoreButton.kt | 17 ++++++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/samples/user-interface/appwidgets/README.md b/samples/user-interface/appwidgets/README.md index f39fb17f..d0f7ccb3 100644 --- a/samples/user-interface/appwidgets/README.md +++ b/samples/user-interface/appwidgets/README.md @@ -1,6 +1,6 @@ # AppWidgets samples -This module demonstrates how to build Android AppWidgets with [Jetpack Glance](https://developer.android.com/jetpack/androidx/releases/glance). +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). The new set of APIs allows an application to build more engaging and beautiful widgets. Use this sample together with the @@ -8,11 +8,27 @@ sample together with the # Pre-requisites -* Android 12 (API level 31) +* Android 12 (API level 31) for Jetpack Glance samples; Android 17 (API level 37) for Remote Compose samples * Android Studio Chipmunk or newer # Getting Started +## Remote Compose Widget Sample (`remote_compose`) + +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. + +### When to use Jetpack Glance vs. Remote Compose + +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. + +However, you cannot build some widget use cases with Jetpack Glance, such as widgets that require animations. Jetpack Glance uses the legacy `RemoteViews` system: +* `RemoteViews` cannot evaluate mathematical expressions on the host (for example, to continuously rotate an object). +* The Android system controls `RemoteViews` and limits the size and frequency of widget updates. + +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. + +> **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). + ## Canonical Widget Layout Samples These [layouts](./src/main/java/com/example/platform/ui/appwidgets/glance/layout) demonstrate how diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt index bf88905e..30833d34 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/ClaimCreditButton.kt @@ -30,6 +30,7 @@ import androidx.compose.remote.creation.compose.state.rdp import androidx.compose.remote.creation.compose.state.rs import androidx.compose.remote.creation.compose.widgets.onClick import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.asImageBitmap import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap @@ -43,10 +44,12 @@ fun ClaimCreditButton( widgetId: Int, isClaimVisible: RemoteInt, ) { - val bitmap = ContextCompat.getDrawable(context, R.drawable.claim_credit_button)!!.toBitmap() + val imageBitmap = remember(context) { + ContextCompat.getDrawable(context, R.drawable.claim_credit_button)!!.toBitmap().asImageBitmap() + } RemoteImage( - remoteBitmap = bitmap.asImageBitmap().rb, + remoteBitmap = imageBitmap.rb, contentDescription = "Claim Credit".rs, modifier = RemoteModifier .padding(end = 68.rdp, bottom = 12.rdp) diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt index f448c9b6..1dfe5b6f 100644 --- a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/remote_compose/StoreButton.kt @@ -32,6 +32,7 @@ import androidx.compose.remote.creation.compose.state.rdp import androidx.compose.remote.creation.compose.state.rs import androidx.compose.remote.creation.compose.widgets.onClick import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.asImageBitmap import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap @@ -44,10 +45,12 @@ fun StoreButton( context: Context, widgetId: Int, ) { - val bitmap = ContextCompat.getDrawable(context, R.drawable.shop_button)!!.toBitmap() + val imageBitmap = remember(context) { + ContextCompat.getDrawable(context, R.drawable.shop_button)!!.toBitmap().asImageBitmap() + } RemoteImage( - remoteBitmap = bitmap.asImageBitmap().rb, + remoteBitmap = imageBitmap.rb, contentDescription = "Store".rs, modifier = RemoteModifier .padding(12.rdp) @@ -61,10 +64,16 @@ fun StoreButton( internal fun launchStoreActivity(context: Context, widgetId: Int) { val intent = Intent(context, StoreActivity::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) + // FLAG_ACTIVITY_NEW_TASK is required when starting an Activity from a BroadcastReceiver + // context (RemoteComposeWidget dispatches onClick lambdas from onReceive). + // FLAG_ACTIVITY_CLEAR_TOP and FLAG_ACTIVITY_SINGLE_TOP reuse an existing StoreActivity + // instance if one is already open and deliver the newly selected widgetId via onNewIntent. flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_CLEAR_TOP or - Intent.FLAG_ACTIVITY_SINGLE_TOP + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP } + // Starting in Android 14 (API 34), apps must explicitly opt in to allow background activity + // starts when triggered from a PendingIntent broadcast (such as a widget click). if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { val options = ActivityOptions.makeBasic().apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {