From 5a02a996c696273bc3ac36f5b1372eb56e5a914b Mon Sep 17 00:00:00 2001 From: Shamali Patwa Date: Mon, 2 Jun 2025 11:18:30 -0700 Subject: [PATCH 1/4] Add widget picker drag and add item listeners. These handle those two operations for home screen when widget picker is opened as a separate activity. Bug: 408283627 Flag: com.android.launcher3.enable_widget_picker_refactor Test: Not applicable. Test: See demo in comments Change-Id: I37f626b57610379a8af4e7a229406ed0656806c2 --- .../listeners/WidgetPickerAddItemListener.kt | 58 +++++++++++++ .../listeners/WidgetPickerDragItemListener.kt | 87 +++++++++++++++++++ .../android/launcher3/dragndrop/DragView.java | 12 ++- .../widget/PendingItemDragHelper.java | 69 ++++++++++++++- 4 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 compose/features/com/android/launcher3/widgetpicker/listeners/WidgetPickerAddItemListener.kt create mode 100644 compose/features/com/android/launcher3/widgetpicker/listeners/WidgetPickerDragItemListener.kt diff --git a/compose/features/com/android/launcher3/widgetpicker/listeners/WidgetPickerAddItemListener.kt b/compose/features/com/android/launcher3/widgetpicker/listeners/WidgetPickerAddItemListener.kt new file mode 100644 index 0000000000..330b66a14f --- /dev/null +++ b/compose/features/com/android/launcher3/widgetpicker/listeners/WidgetPickerAddItemListener.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://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.android.launcher3.widgetpicker.listeners + +import android.appwidget.AppWidgetProviderInfo +import android.view.View +import com.android.launcher3.Launcher +import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_WIDGETS_TRAY +import com.android.launcher3.logging.StatsLogManager.LauncherEvent +import com.android.launcher3.util.ContextTracker.SchedulerCallback +import com.android.launcher3.widget.LauncherAppWidgetProviderInfo +import com.android.launcher3.widget.PendingAddWidgetInfo + +/** + * A callback listener (for tap-to-add flow) that handles adding a widget from a separate widget + * picker activity. Invoked once widget picker is closed and home screen is showing / ready. + * + * Also logs to stats logger once widget is added. + */ +class WidgetPickerAddItemListener(private val providerInfo: AppWidgetProviderInfo) : + SchedulerCallback { + override fun init(launcher: Launcher?, isHomeStarted: Boolean): Boolean { + checkNotNull(launcher) + + val launcherProviderInfo = + LauncherAppWidgetProviderInfo.fromProviderInfo(launcher, providerInfo) + val pendingAddWidgetInfo = + PendingAddWidgetInfo(launcherProviderInfo, CONTAINER_WIDGETS_TRAY) + + val view = View(launcher) + view.tag = pendingAddWidgetInfo + + launcher.accessibilityDelegate?.addToWorkspace( + /*item=*/ pendingAddWidgetInfo, + /*accessibility=*/ false + ) { + launcher.statsLogManager + .logger() + .withItemInfo(pendingAddWidgetInfo) + .log(LauncherEvent.LAUNCHER_WIDGET_ADD_BUTTON_TAP) + } + return false // don't receive any more callbacks as we got launcher and handled it + } +} diff --git a/compose/features/com/android/launcher3/widgetpicker/listeners/WidgetPickerDragItemListener.kt b/compose/features/com/android/launcher3/widgetpicker/listeners/WidgetPickerDragItemListener.kt new file mode 100644 index 0000000000..3b3e8c3a4e --- /dev/null +++ b/compose/features/com/android/launcher3/widgetpicker/listeners/WidgetPickerDragItemListener.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://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.android.launcher3.widgetpicker.listeners + +import android.appwidget.AppWidgetProviderInfo +import android.graphics.Rect +import android.view.View +import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_WIDGETS_TRAY +import com.android.launcher3.dragndrop.BaseItemDragListener +import com.android.launcher3.widget.DatabaseWidgetPreviewLoader.WidgetPreviewInfo +import com.android.launcher3.widget.LauncherAppWidgetProviderInfo +import com.android.launcher3.widget.PendingAddWidgetInfo +import com.android.launcher3.widget.PendingItemDragHelper +import com.android.launcher3.widgetpicker.shared.model.WidgetPreview + +/** + * A callback listener of type [BaseItemDragListener] that handles widget drag and drop from widget + * picker hosted in a separate activity than home screen. + * + * Responsible for initializing the [PendingItemDragHelper] that then handles the rest of the + * drag and drop (including showing a drag shadow for the widget). + * + * @param mimeType a mime type used by widget picker when attaching this listener for a specific + * widget's drag and drop session. + * @param appWidgetProviderInfo provider info of the widget being dragged + * @param previewRect the bounds of widget's preview offset by the point of long press + * @param previewWidth width of the preview as it appears in the widget picker. + */ +class WidgetPickerDragItemListener( + private val mimeType: String, + private val appWidgetProviderInfo: AppWidgetProviderInfo, + private val widgetPreview: WidgetPreview, + previewRect: Rect, + previewWidth: Int +) : BaseItemDragListener(previewRect, previewWidth, previewWidth) { + override fun getMimeType(): String = mimeType + + override fun createDragHelper(): PendingItemDragHelper { + val launcherProviderInfo = + LauncherAppWidgetProviderInfo.fromProviderInfo(mLauncher, appWidgetProviderInfo) + val pendingAddWidgetInfo = + PendingAddWidgetInfo(launcherProviderInfo, CONTAINER_WIDGETS_TRAY) + + val view = View(mLauncher) + view.tag = pendingAddWidgetInfo + + val dragHelper = PendingItemDragHelper(view) + + val info = WidgetPreviewInfo() + when (widgetPreview) { + is WidgetPreview.BitmapWidgetPreview -> { + info.previewBitmap = widgetPreview.bitmap + info.providerInfo = appWidgetProviderInfo + } + + is WidgetPreview.ProviderInfoWidgetPreview -> { + info.providerInfo = widgetPreview.providerInfo + } + + is WidgetPreview.RemoteViewsWidgetPreview -> { + info.remoteViews = widgetPreview.remoteViews + info.providerInfo = appWidgetProviderInfo + } + + else -> throw IllegalStateException( + "Unsupported preview type when dropping widget to launcher" + ) + } + dragHelper.setWidgetPreviewInfo(info) + + return dragHelper + } +} diff --git a/src/com/android/launcher3/dragndrop/DragView.java b/src/com/android/launcher3/dragndrop/DragView.java index 981b78bb79..89c63511d5 100644 --- a/src/com/android/launcher3/dragndrop/DragView.java +++ b/src/com/android/launcher3/dragndrop/DragView.java @@ -20,6 +20,7 @@ import static android.view.View.MeasureSpec.EXACTLY; import static android.view.View.MeasureSpec.makeMeasureSpec; import static com.android.launcher3.LauncherAnimUtils.VIEW_ALPHA; +import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET; import static com.android.launcher3.icons.FastBitmapDrawable.getDisabledColorFilter; import static com.android.launcher3.util.Executors.MODEL_EXECUTOR; @@ -58,6 +59,7 @@ import androidx.dynamicanimation.animation.SpringAnimation; import androidx.dynamicanimation.animation.SpringForce; import com.android.app.animation.Interpolators; +import com.android.launcher3.Flags; import com.android.launcher3.R; import com.android.launcher3.Utilities; import com.android.launcher3.graphics.ThemeManager; @@ -116,6 +118,7 @@ public abstract class DragView extends Fram private SpringFloatValue mTranslateX, mTranslateY; private Path mScaledMaskPath; private Drawable mBadge; + private int mItemType; public DragView(T launcher, Drawable drawable, int registrationX, int registrationY, final float initialScale, final float scaleOnDrop, @@ -244,6 +247,7 @@ public abstract class DragView extends Fram */ @TargetApi(Build.VERSION_CODES.O) public void setItemInfo(final ItemInfo info) { + mItemType = info.itemType; // Load the adaptive icon on a background thread and add the view in ui thread. MODEL_EXECUTOR.getHandler().postAtFrontOfQueue(() -> { ThemeManager themeManager = ThemeManager.INSTANCE.get(getContext()); @@ -627,7 +631,13 @@ public abstract class DragView extends Fram for (int i = dragLayer.getChildCount() - 1; i >= 0; i--) { View child = dragLayer.getChildAt(i); if (child instanceof DragView) { - dragLayer.removeView(child); + // Widgets uses a listener to remove views. + // When widgets are dropped from another window, we don't want to remove the + // dragView on resume of launcher. + if (Flags.enableWidgetPickerRefactor() + && ((DragView) child).mItemType != ITEM_TYPE_APPWIDGET) { + dragLayer.removeView(child); + } } } } diff --git a/src/com/android/launcher3/widget/PendingItemDragHelper.java b/src/com/android/launcher3/widget/PendingItemDragHelper.java index 2c3ac26503..462309a9d5 100644 --- a/src/com/android/launcher3/widget/PendingItemDragHelper.java +++ b/src/com/android/launcher3/widget/PendingItemDragHelper.java @@ -29,6 +29,7 @@ import android.view.View; import android.view.View.MeasureSpec; import android.widget.RemoteViews; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.launcher3.DeviceProfile; @@ -44,6 +45,9 @@ import com.android.launcher3.icons.BaseIconFactory; import com.android.launcher3.icons.FastBitmapDrawable; import com.android.launcher3.icons.LauncherIcons; import com.android.launcher3.icons.RoundDrawableWrapper; +import com.android.launcher3.widget.DatabaseWidgetPreviewLoader.WidgetPreviewInfo; + +import java.util.Objects; /** * Extension of {@link DragPreviewProvider} with logic specific to pending widgets/shortcuts @@ -57,6 +61,7 @@ public class PendingItemDragHelper extends DragPreviewProvider { private int[] mEstimatedCellSize; @Nullable private RemoteViews mRemoteViewsPreview; + @Nullable private WidgetPreviewInfo mWidgetPreviewInfo; private float mRemoteViewsPreviewScale = 1f; @Nullable private NavigableAppWidgetHostView mAppWidgetHostViewPreview; private final float mEnforcedRoundedCornersForWidget; @@ -68,6 +73,13 @@ public class PendingItemDragHelper extends DragPreviewProvider { view.getContext()); } + /** + * Set the necessary information about the preview, so a preview can be built for drag and drop. + */ + public void setWidgetPreviewInfo(@NonNull WidgetPreviewInfo previewInfo) { + mWidgetPreviewInfo = previewInfo; + } + /** * Sets a {@link RemoteViews} which shows an app widget preview provided by app developers in * the pin widget flow. @@ -115,7 +127,30 @@ public class PendingItemDragHelper extends DragPreviewProvider { int[] previewSizeBeforeScale = new int[1]; - if (mRemoteViewsPreview != null) { + if (mWidgetPreviewInfo != null) { + if (mWidgetPreviewInfo.previewBitmap != null) { + Drawable drawable = new FastBitmapDrawable(mWidgetPreviewInfo.previewBitmap); + drawable = new RoundDrawableWrapper(drawable, mEnforcedRoundedCornersForWidget); + preview = drawable; + if (drawable.getIntrinsicWidth() > 0 + && drawable.getIntrinsicHeight() > 0) { + previewSizeBeforeScale[0] = drawable.getIntrinsicWidth(); + } + } else { + mAppWidgetHostViewPreview = new LauncherAppWidgetHostView(launcher); + mAppWidgetHostViewPreview.setAppWidget(/* appWidgetId= */ -1, + mWidgetPreviewInfo.providerInfo); + mAppWidgetHostViewPreview.setClipChildren(false); + mAppWidgetHostViewPreview.setClipToPadding(false); + mAppWidgetHostViewPreview.updateAppWidget(/* remoteViews= */ + mWidgetPreviewInfo.remoteViews); + + DeviceProfile deviceProfile = launcher.getDeviceProfile(); + Size widgetSizes = getWidgetSizePx(deviceProfile, mAddInfo.spanX, + mAddInfo.spanY); + measureAndUpdateAppWidgetHostViewScale(widgetSizes); + } + } else if (mRemoteViewsPreview != null) { mAppWidgetHostViewPreview = new LauncherAppWidgetHostView(launcher); mAppWidgetHostViewPreview.setAppWidget(/* appWidgetId= */ -1, ((PendingAddWidgetInfo) mAddInfo).info); @@ -215,6 +250,38 @@ public class PendingItemDragHelper extends DragPreviewProvider { } } + private void measureAndUpdateAppWidgetHostViewScale(Size widgetSizes) { + Objects.requireNonNull(mAppWidgetHostViewPreview).measure( + MeasureSpec.makeMeasureSpec(widgetSizes.getWidth(), + MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(widgetSizes.getHeight(), + MeasureSpec.EXACTLY)); + + // Scale the preview to fit the widget's size. Not all widgets fill bounds, so we need to + // scale them. + if (mAppWidgetHostViewPreview.getChildCount() == 1) { + View content = mAppWidgetHostViewPreview.getChildAt(0); + float contentWidth = content.getMeasuredWidth(); + float contentHeight = content.getMeasuredHeight(); + if (contentWidth > 0 && contentHeight > 0) { + + // Take the content width based on the edge furthest from the center, so that when + // scaling the hostView, the farthest edge is still visible. + contentWidth = 2 * Math.max(contentWidth / 2 - content.getLeft(), + content.getRight() - contentWidth / 2); + contentHeight = 2 * Math.max(contentHeight / 2 - content.getTop(), + content.getBottom() - contentHeight / 2); + + if (contentWidth > 0 && contentHeight > 0) { + float pWidth = widgetSizes.getWidth(); + float pHeight = widgetSizes.getHeight(); + mAppWidgetHostViewPreview.setScaleToFit( + Math.min(pWidth / contentWidth, pHeight / contentHeight)); + } + } + } + } + @Override protected Bitmap convertPreviewToAlphaBitmap(Bitmap preview) { if (mAddInfo instanceof PendingAddShortcutInfo || mEstimatedCellSize == null) { From 6710f2faf2a24a630b5bfccd55d45c4b10e26e68 Mon Sep 17 00:00:00 2001 From: Shamali Patwa Date: Fri, 30 May 2025 13:40:37 -0700 Subject: [PATCH 2/4] Pass down the widget interaction callback and flag on whether to support drag shadow Bug: 408283627 Flag: com.android.launcher3.enable_widget_picker_refactor Test: N/A Change-Id: I8d04cb3b0e52c202bf6223e0b0e2e10a4ca6fdb8 --- modules/widgetpicker/Android.bp | 15 +++++- modules/widgetpicker/build.gradle.kts | 1 + .../ui/WidgetPickerEventListeners.kt | 40 +++++++++++++- .../ui/components/WidgetAppsList.kt | 13 ++++- .../ui/fullcatalog/FullWidgetsCatalog.kt | 8 ++- .../screens/landing/LandingScreen.kt | 24 +++++++-- .../landing/LandingScreenSinglePane.kt | 11 +++- .../screens/landing/LandingScreenTwoPane.kt | 52 ++++++++++++++----- .../screens/search/SearchScreen.kt | 35 ++++++++++--- .../components/WidgetsGridScreenshotTest.kt | 2 + .../landing/LandingScreenSinglePaneTest.kt | 3 ++ .../landing/LandingScreenTwoPaneTest.kt | 2 + .../screens/search/SearchScreenTest.kt | 4 +- 13 files changed, 179 insertions(+), 31 deletions(-) diff --git a/modules/widgetpicker/Android.bp b/modules/widgetpicker/Android.bp index 0342ea6e14..0c77dcce2f 100644 --- a/modules/widgetpicker/Android.bp +++ b/modules/widgetpicker/Android.bp @@ -143,6 +143,7 @@ android_library { "src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsList.kt", ], static_libs: [ + "widget_picker_event_listeners", "widget_picker_ui_data_types", "androidx.compose.foundation_foundation", "androidx.compose.foundation_foundation-layout", @@ -162,6 +163,18 @@ android_library { ], } +android_library { + name: "widget_picker_event_listeners", + sdk_version: "current", + min_sdk_version: min_launcher3_sdk_version, + srcs: [ + "src/com/android/launcher3/widgetpicker/ui/WidgetPickerEventListeners.kt", + ], + static_libs: [ + "widget_picker_shared_data_types", + ], +} + android_library { name: "widget_picker_ui", sdk_version: "current", @@ -177,10 +190,10 @@ android_library { "src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenViewModel.kt", "src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreen.kt", "src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreenViewModel.kt", - "src/com/android/launcher3/widgetpicker/ui/WidgetPickerEventListeners.kt", ], static_libs: [ "widget_picker_ui_components", + "widget_picker_event_listeners", "widget_picker_ui_data_types", "widget_picker_domain_layer", "dagger2", diff --git a/modules/widgetpicker/build.gradle.kts b/modules/widgetpicker/build.gradle.kts index 227abebff6..8df71f690c 100644 --- a/modules/widgetpicker/build.gradle.kts +++ b/modules/widgetpicker/build.gradle.kts @@ -122,6 +122,7 @@ dependencies { // Shared testing libs testImplementation(project(":RobolectricLib")) + testImplementation(project(":SharedTestLib")) androidTestImplementation(project(":SharedTestLib")) androidTestImplementation(project(":PlatformParameterizedLib")) androidTestImplementation(project(":ScreenshotLib")) diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/WidgetPickerEventListeners.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/WidgetPickerEventListeners.kt index 7773284937..9973b79398 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/WidgetPickerEventListeners.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/WidgetPickerEventListeners.kt @@ -14,7 +14,11 @@ * limitations under the License. */ -package com.android.launcher3.widgetpicker +package com.android.launcher3.widgetpicker.ui + +import android.appwidget.AppWidgetProviderInfo +import android.graphics.Rect +import com.android.launcher3.widgetpicker.shared.model.WidgetPreview /** * General interface that clients can implement to listen to events from different types of @@ -23,4 +27,38 @@ package com.android.launcher3.widgetpicker interface WidgetPickerEventListeners { /** Called when the widget picker is dismissed. */ fun onClose() + + /** Called when a widget is being dragged or added from picker. */ + fun onWidgetInteraction(widgetInteractionInfo: WidgetInteractionInfo) +} + +/** Information passed in event listener when a widget is dragged or added from picker. */ +sealed class WidgetInteractionInfo { + /** + * Information passed in event listener when a widget is dragged. + * + * @param providerInfo metadata for the provider of the widget being dragged. + * @param bounds current bounds of the widget's preview considering the drag offset and scale. + * @param widthPx measured width of the preview. + * @param heightPx measured height of the preview. + * @param previewInfo information necessary to render a preview within host + * @param mimeType a unique mime type set on clip data for the drag session + */ + data class WidgetDragInfo( + val providerInfo: AppWidgetProviderInfo, + val bounds: Rect, + val widthPx: Int, + val heightPx: Int, + val previewInfo: WidgetPreview, + val mimeType: String, + ) : WidgetInteractionInfo() + + /** + * Information passed in event listener when a widget is added using tap to add. + * + * @param providerInfo metadata for the provider of the widget being added. + */ + data class WidgetAddInfo( + val providerInfo: AppWidgetProviderInfo + ) : WidgetInteractionInfo() } diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsList.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsList.kt index d880a9aecf..4ddf5aa7eb 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsList.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsList.kt @@ -36,6 +36,7 @@ import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon import com.android.launcher3.widgetpicker.shared.model.WidgetAppId import com.android.launcher3.widgetpicker.shared.model.WidgetId import com.android.launcher3.widgetpicker.shared.model.WidgetPreview +import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo import com.android.launcher3.widgetpicker.ui.model.DisplayableWidgetApp /** @@ -53,6 +54,8 @@ fun WidgetAppsList( onWidgetAppClick: (DisplayableWidgetApp) -> Unit, appIcons: Map, widgetPreviews: Map, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, headerDescriptionStyle: AppHeaderDescriptionStyle = AppHeaderDescriptionStyle.WIDGETS_COUNT, ) { val listState = rememberLazyListState() @@ -95,6 +98,8 @@ fun WidgetAppsList( description = description, widgetPreviews = widgetPreviews, onWidgetAppClick = onWidgetAppClick, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } @@ -124,6 +129,8 @@ private fun ExpandableWidgetAppHeader( description: String, widgetPreviews: Map, onWidgetAppClick: (DisplayableWidgetApp) -> Unit, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, ) { val expandedContent: @Composable () -> Unit = remember(widgetApp, widgetPreviews) { @@ -133,7 +140,8 @@ private fun ExpandableWidgetAppHeader( showAllWidgetDetails = true, previews = widgetPreviews, modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .background( color = MaterialTheme.colorScheme.surfaceBright, shape = @@ -142,6 +150,8 @@ private fun ExpandableWidgetAppHeader( else -> WidgetAppsListDimensions.smallShape }, ), + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } @@ -156,6 +166,7 @@ private fun ExpandableWidgetAppHeader( onClick = { onWidgetAppClick(widgetApp) }, shape = when { + isFirst && isLast && !expanded -> WidgetAppsListDimensions.largeShape isFirst -> WidgetAppsListDimensions.topLargeShape isLast && !expanded -> WidgetAppsListDimensions.bottomLargeShape else -> WidgetAppsListDimensions.smallShape diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/FullWidgetsCatalog.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/FullWidgetsCatalog.kt index 6242a5f1c8..e411bf5aab 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/FullWidgetsCatalog.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/FullWidgetsCatalog.kt @@ -21,7 +21,7 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration -import com.android.launcher3.widgetpicker.WidgetPickerEventListeners +import com.android.launcher3.widgetpicker.ui.WidgetPickerEventListeners import com.android.launcher3.widgetpicker.ui.components.ModalBottomSheetHeightStyle import com.android.launcher3.widgetpicker.ui.components.TitledBottomSheet import com.android.launcher3.widgetpicker.ui.components.TitledBottomSheetDefaults @@ -62,7 +62,7 @@ class FullWidgetsCatalog @Inject constructor( TitledBottomSheet( title = viewModel.title.takeIf { !isCompactHeight }, modifier = Modifier, - description = null, + description = viewModel.description, heightStyle = ModalBottomSheetHeightStyle.FILL_HEIGHT, showDragHandle = true, onDismissRequest = { eventListeners.onClose() }, @@ -72,6 +72,8 @@ class FullWidgetsCatalog @Inject constructor( LandingScreen( isCompact = isCompactWidth, onEnterSearchMode = { viewModel.onActiveScreenChange(Screen.SEARCH) }, + onWidgetInteraction = eventListeners::onWidgetInteraction, + showDragShadow = viewModel.showDragShadow, viewModel = viewModel.landingScreenViewModel, ) } @@ -80,6 +82,8 @@ class FullWidgetsCatalog @Inject constructor( SearchScreen( isCompact = isCompactWidth, onExitSearchMode = { viewModel.onActiveScreenChange(Screen.LANDING) }, + onWidgetInteraction = eventListeners::onWidgetInteraction, + showDragShadow = viewModel.showDragShadow, viewModel = viewModel.searchScreenViewModel, ) } diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreen.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreen.kt index 393655aa5a..fb69be4a7b 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreen.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreen.kt @@ -18,12 +18,14 @@ package com.android.launcher3.widgetpicker.ui.fullcatalog.screens.landing import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.android.launcher3.widgetpicker.shared.model.WidgetAppId +import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo import com.android.launcher3.widgetpicker.ui.components.WidgetsGrid import com.android.launcher3.widgetpicker.ui.components.WidgetsSearchBar @@ -32,12 +34,17 @@ import com.android.launcher3.widgetpicker.ui.components.WidgetsSearchBar * * @param isCompact indicates whether to show the compact single pane layout or the two pane layout. * @param onEnterSearchMode callback for when user focuses on the search bar. + * @param onWidgetInteraction callback for when user interacts with a widget. + * @param showDragShadow indicates whether to show the drag shadow when user long presses on a + * widget to drag it. * @param viewModel the view model backing the state and data for the landing screen. */ @Composable fun LandingScreen( isCompact: Boolean, onEnterSearchMode: () -> Unit, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, viewModel: LandingScreenViewModel, ) { val browseState = viewModel.browseWidgetsState @@ -74,6 +81,8 @@ fun LandingScreen( onPersonalWidgetAppToggle = viewModel::onSelectedPersonalAppToggle, selectedWorkWidgetAppId = viewModel.selectedWorkAppId, onWorkWidgetAppToggle = viewModel::onSelectedWorkAppToggle, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } @@ -92,16 +101,21 @@ private fun LandingScreen( onPersonalWidgetAppToggle: (WidgetAppId?) -> Unit, selectedWorkWidgetAppId: WidgetAppId?, onWorkWidgetAppToggle: (WidgetAppId?) -> Unit, -) { + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, + ) { val featuredWidgetsContent: @Composable () -> Unit = { WidgetsGrid( modifier = Modifier .fillMaxSize() + .wrapContentSize() .verticalScroll(rememberScrollState()), widgetSizeGroups = featuredWidgetsState.sizeGroups, showAllWidgetDetails = false, previews = featuredWidgetPreviewsState.previews, - appIcons = widgetAppIconsState.icons + appIcons = widgetAppIconsState.icons, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } @@ -117,7 +131,9 @@ private fun LandingScreen( selectedPersonalWidgetAppId = selectedPersonalWidgetAppId, onPersonalWidgetAppToggle = onPersonalWidgetAppToggle, selectedWorkWidgetAppId = selectedWorkWidgetAppId, - onWorkWidgetAppToggle = onWorkWidgetAppToggle + onWorkWidgetAppToggle = onWorkWidgetAppToggle, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) else -> @@ -133,6 +149,8 @@ private fun LandingScreen( onPersonalWidgetAppToggle = onPersonalWidgetAppToggle, selectedWorkWidgetAppId = selectedWorkWidgetAppId, onWorkWidgetAppToggle = onWorkWidgetAppToggle, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePane.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePane.kt index ec4a2238a8..49db26b667 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePane.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePane.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import com.android.launcher3.widgetpicker.R import com.android.launcher3.widgetpicker.shared.model.WidgetAppId import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfile +import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo import com.android.launcher3.widgetpicker.ui.components.AppHeaderDescriptionStyle import com.android.launcher3.widgetpicker.ui.components.LeadingIconToolbarTab import com.android.launcher3.widgetpicker.ui.components.ScrollableFloatingToolbar @@ -77,6 +78,8 @@ fun LandingScreenSinglePane( onPersonalWidgetAppToggle: (WidgetAppId) -> Unit, selectedWorkWidgetAppId: WidgetAppId?, onWorkWidgetAppToggle: (WidgetAppId) -> Unit, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, ) { val hasWorkProfile = remember(browseWidgetsState) { browseWidgetsState.workProfile != null } @@ -133,7 +136,9 @@ fun LandingScreenSinglePane( onPersonalWidgetAppToggle(widgetApp.id) }, appIcons = widgetAppIconsState.icons, - widgetPreviews = personalWidgetPreviewsState.previews + widgetPreviews = personalWidgetPreviewsState.previews, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } @@ -150,7 +155,9 @@ fun LandingScreenSinglePane( onWorkWidgetAppToggle(widgetApp.id) }, appIcons = widgetAppIconsState.icons, - widgetPreviews = workWidgetPreviewsState.previews + widgetPreviews = workWidgetPreviewsState.previews, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPane.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPane.kt index ebd5b9d63a..9929405557 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPane.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPane.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp import com.android.launcher3.widgetpicker.R import com.android.launcher3.widgetpicker.shared.model.WidgetAppId import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfile +import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo import com.android.launcher3.widgetpicker.ui.components.AppHeaderDescriptionStyle import com.android.launcher3.widgetpicker.ui.components.LeadingIconToolbarTab import com.android.launcher3.widgetpicker.ui.components.ScrollableFloatingToolbar @@ -63,7 +64,6 @@ import com.android.launcher3.widgetpicker.ui.fullcatalog.screens.landing.Landing import com.android.launcher3.widgetpicker.ui.fullcatalog.screens.landing.LandingScreenTwoPaneDimens.pagerItemsSpacing import kotlinx.coroutines.launch - /** * A composable function that provides a two pane layout for landing screen of the full catalog * of widgets in the widget picker. @@ -83,7 +83,9 @@ fun LandingScreenTwoPane( onPersonalWidgetAppToggle: (WidgetAppId?) -> Unit, selectedWorkWidgetAppId: WidgetAppId?, onWorkWidgetAppToggle: (WidgetAppId?) -> Unit, -) { + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, + ) { val hasWorkProfile = remember(browseWidgetsState) { browseWidgetsState.workProfile != null } var isFeaturedSectionShowing by rememberSaveable { mutableStateOf(true) } val pageCount = remember { @@ -128,6 +130,8 @@ fun LandingScreenTwoPane( isFeaturedSectionShowing = false onWorkWidgetAppToggle(id) }, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) }, rightPaneTitle = rightPaneTitle( @@ -148,6 +152,8 @@ fun LandingScreenTwoPane( widgetAppIconsState = widgetAppIconsState, selectedWorkWidgetAppId = selectedWorkWidgetAppId, workWidgetPreviewsState = workWidgetPreviewsState, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } ) @@ -203,8 +209,10 @@ private fun RightPaneContent( personalWidgetPreviewsState: PreviewsState, widgetAppIconsState: AppIconsState, selectedWorkWidgetAppId: WidgetAppId?, - workWidgetPreviewsState: PreviewsState -) { + workWidgetPreviewsState: PreviewsState, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, + ) { when { isFeaturedSectionSelected -> featuredWidgets() @@ -227,7 +235,9 @@ private fun RightPaneContent( showAllWidgetDetails = true, widgetSizeGroups = selectedPersonalWidgets, previews = personalWidgetPreviewsState.previews, - appIcons = widgetAppIconsState.icons + appIcons = widgetAppIconsState.icons, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } @@ -251,7 +261,9 @@ private fun RightPaneContent( showAllWidgetDetails = true, widgetSizeGroups = selectedWorkWidgets, previews = workWidgetPreviewsState.previews, - appIcons = widgetAppIconsState.icons + appIcons = widgetAppIconsState.icons, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } @@ -273,7 +285,9 @@ private fun LeftPaneContent( selectedWorkWidgetAppId: WidgetAppId?, workWidgetPreviewsState: PreviewsState, onWorkWidgetAppToggle: (WidgetAppId) -> Unit, -) { + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, + ) { Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { SelectableSuggestionsHeader( @@ -303,7 +317,9 @@ private fun LeftPaneContent( selectedPersonalWidgetAppId = selectedPersonalWidgetAppId, widgetAppIconsState = widgetAppIconsState, personalWidgetPreviewsState = personalWidgetPreviewsState, - onPersonalWidgetAppToggle = onPersonalWidgetAppToggle + onPersonalWidgetAppToggle = onPersonalWidgetAppToggle, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } @@ -313,7 +329,9 @@ private fun LeftPaneContent( selectedWorkWidgetAppId = selectedWorkWidgetAppId, widgetAppIconsState = widgetAppIconsState, workWidgetPreviewsState = workWidgetPreviewsState, - onWorkWidgetAppToggle = onWorkWidgetAppToggle + onWorkWidgetAppToggle = onWorkWidgetAppToggle, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } @@ -337,8 +355,10 @@ private fun PersonalSection( selectedPersonalWidgetAppId: WidgetAppId?, widgetAppIconsState: AppIconsState, personalWidgetPreviewsState: PreviewsState, - onPersonalWidgetAppToggle: (WidgetAppId) -> Unit -) { + onPersonalWidgetAppToggle: (WidgetAppId) -> Unit, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, + ) { Box(modifier = Modifier.fillMaxSize()) { WidgetAppsList( modifier = Modifier.fillMaxSize(), @@ -351,6 +371,8 @@ private fun PersonalSection( onWidgetAppClick = { widgetApp -> onPersonalWidgetAppToggle(widgetApp.id) }, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } @@ -361,8 +383,10 @@ private fun WorkSection( selectedWorkWidgetAppId: WidgetAppId?, widgetAppIconsState: AppIconsState, workWidgetPreviewsState: PreviewsState, - onWorkWidgetAppToggle: (WidgetAppId) -> Unit -) { + onWorkWidgetAppToggle: (WidgetAppId) -> Unit, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, + ) { Box(modifier = Modifier.fillMaxSize()) { WidgetAppsList( modifier = Modifier.fillMaxSize(), @@ -375,6 +399,8 @@ private fun WorkSection( onWidgetAppClick = { widgetApp -> onWorkWidgetAppToggle(widgetApp.id) }, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreen.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreen.kt index e61d6a24f9..5bba364ace 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreen.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.android.launcher3.widgetpicker.R import com.android.launcher3.widgetpicker.shared.model.WidgetAppId +import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo import com.android.launcher3.widgetpicker.ui.components.AppHeaderDescriptionStyle import com.android.launcher3.widgetpicker.ui.components.SinglePaneLayout import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayout @@ -45,7 +46,9 @@ import com.android.launcher3.widgetpicker.ui.fullcatalog.screens.landing.Preview fun SearchScreen( isCompact: Boolean, onExitSearchMode: () -> Unit, - viewModel: SearchScreenViewModel, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, + viewModel: SearchScreenViewModel ) { SearchScreen( isCompact = isCompact, @@ -57,6 +60,8 @@ fun SearchScreen( onSearch = viewModel::onQueryChange, onSelectedWidgetAppToggle = viewModel::onSelectedWidgetAppToggle, onExitSearchMode = onExitSearchMode, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } @@ -71,6 +76,8 @@ private fun SearchScreen( onSearch: (String) -> Unit, onSelectedWidgetAppToggle: (id: WidgetAppId) -> Unit, onExitSearchMode: () -> Unit, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, ) { val searchBar: @Composable () -> Unit = { WidgetsSearchBar( @@ -92,7 +99,9 @@ private fun SearchScreen( selectedWidgetAppId = selectedWidgetAppId, appIconsState = appIconsState, widgetPreviewsState = widgetPreviewsState, - onSelectedWidgetAppChange = onSelectedWidgetAppToggle + onSelectedWidgetAppChange = onSelectedWidgetAppToggle, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } else { SearchScreenTwoPane( @@ -101,7 +110,9 @@ private fun SearchScreen( selectedWidgetAppId = selectedWidgetAppId, appIconsState = appIconsState, widgetPreviewsState = widgetPreviewsState, - onSelectedWidgetAppChange = onSelectedWidgetAppToggle + onSelectedWidgetAppChange = onSelectedWidgetAppToggle, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } @@ -113,7 +124,9 @@ private fun SearchScreenSinglePane( selectedWidgetAppId: WidgetAppId?, appIconsState: AppIconsState, widgetPreviewsState: PreviewsState, - onSelectedWidgetAppChange: (id: WidgetAppId) -> Unit + onSelectedWidgetAppChange: (id: WidgetAppId) -> Unit, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, ) { SinglePaneLayout( searchBar = searchBar, @@ -129,7 +142,9 @@ private fun SearchScreenSinglePane( onSelectedWidgetAppChange(widgetApp.id) }, appIcons = appIconsState.icons, - widgetPreviews = widgetPreviewsState.previews + widgetPreviews = widgetPreviewsState.previews, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } } @@ -143,7 +158,9 @@ fun SearchScreenTwoPane( selectedWidgetAppId: WidgetAppId?, appIconsState: AppIconsState, widgetPreviewsState: PreviewsState, - onSelectedWidgetAppChange: (id: WidgetAppId) -> Unit + onSelectedWidgetAppChange: (id: WidgetAppId) -> Unit, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, ) { TwoPaneLayout( searchBar = searchBar, @@ -160,6 +177,8 @@ fun SearchScreenTwoPane( onWidgetAppClick = { widgetApp -> onSelectedWidgetAppChange(widgetApp.id) }, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } }, @@ -186,7 +205,9 @@ fun SearchScreenTwoPane( showAllWidgetDetails = true, widgetSizeGroups = selectedWidgets, previews = widgetPreviewsState.previews, - appIcons = appIconsState.icons + appIcons = appIconsState.icons, + onWidgetInteraction = onWidgetInteraction, + showDragShadow = showDragShadow, ) } }, diff --git a/modules/widgetpicker/tests/multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGridScreenshotTest.kt b/modules/widgetpicker/tests/multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGridScreenshotTest.kt index e8341487e3..3f8825f94c 100644 --- a/modules/widgetpicker/tests/multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGridScreenshotTest.kt +++ b/modules/widgetpicker/tests/multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGridScreenshotTest.kt @@ -138,8 +138,10 @@ private fun GridPreview( WidgetsGrid( widgetSizeGroups = groups, showAllWidgetDetails = true, + showDragShadow = false, previews = previews, modifier = Modifier.fillMaxWidth().wrapContentHeight(), + onWidgetInteraction = {} ) } } diff --git a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePaneTest.kt b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePaneTest.kt index 851b7708c0..a0f49512d8 100644 --- a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePaneTest.kt +++ b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePaneTest.kt @@ -65,6 +65,7 @@ import org.junit.runner.RunWith class LandingScreenSinglePaneTest { @get:Rule val limitDevicesRule = LimitDevicesRule() + @get:Rule val composeTestRule = createAndroidComposeRule() @@ -123,6 +124,8 @@ class LandingScreenSinglePaneTest { LandingScreen( isCompact = true, onEnterSearchMode = {}, + onWidgetInteraction = {}, + showDragShadow = true, viewModel = viewModel, ) } diff --git a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPaneTest.kt b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPaneTest.kt index f64008ddda..b445504054 100644 --- a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPaneTest.kt +++ b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPaneTest.kt @@ -126,6 +126,8 @@ class LandingScreenTwoPaneTest { LandingScreen( isCompact = false, onEnterSearchMode = {}, + onWidgetInteraction = {}, + showDragShadow = true, viewModel = viewModel, ) } diff --git a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreenTest.kt b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreenTest.kt index 3e5f332baa..80ea155a9d 100644 --- a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreenTest.kt +++ b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreenTest.kt @@ -114,7 +114,9 @@ class SearchScreenTest { SearchScreen( isCompact = isCompact, onExitSearchMode = {}, - viewModel = viewModel, + onWidgetInteraction = {}, + showDragShadow = true, + viewModel = viewModel ) } From acc353194f4719c133437e6b9b70f0ec1988bce3 Mon Sep 17 00:00:00 2001 From: Shamali Patwa Date: Mon, 2 Jun 2025 11:26:16 -0700 Subject: [PATCH 3/4] Implement tap-to-add and drag and drop in the compose widget picker - adds the logic to toggle add button on tapping preview / details - adds logic to handle add button click - adds logic to start drag and drop on long press. The compose's drag and drop handles drag and drop itself, but in this case we need custom interaction to be able to support calling back listeners / setting result for activity etc. So, we use custom logic with android view. Bug: 408283627 Flag: EXEMPT independent module Test: See demo Change-Id: Ie13c919248f8bdd20f90c4eb4a0bec266aedde27 --- modules/widgetpicker/Android.bp | 2 + modules/widgetpicker/res/values/strings.xml | 15 +- .../shared/model/WidgetHostInfo.kt | 4 + .../widgetpicker/ui/components/DragAndDrop.kt | 131 +++++++++ .../widgetpicker/ui/components/TestTag.kt | 20 ++ .../ui/components/WidgetAppIcon.kt | 3 +- .../ui/components/WidgetDetails.kt | 185 +++++++++++- .../ui/components/WidgetPreview.kt | 275 +++++++++++++++--- .../ui/components/WidgetPreviewHostView.kt | 22 ++ .../widgetpicker/ui/components/WidgetsGrid.kt | 80 ++++- .../FullWidgetsCatalogViewModel.kt | 1 + modules/widgetpicker/tests/Android.bp | 1 + .../launcher3/widgetpicker/TestUtils.kt | 20 +- .../ui/components/WidgetInteractionsTest.kt | 179 ++++++++++++ .../landing/LandingScreenSinglePaneTest.kt | 13 +- .../landing/LandingScreenTwoPaneTest.kt | 27 +- 16 files changed, 899 insertions(+), 79 deletions(-) create mode 100644 modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/DragAndDrop.kt create mode 100644 modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/TestTag.kt create mode 100644 modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetInteractionsTest.kt diff --git a/modules/widgetpicker/Android.bp b/modules/widgetpicker/Android.bp index 0c77dcce2f..ac02aa6c73 100644 --- a/modules/widgetpicker/Android.bp +++ b/modules/widgetpicker/Android.bp @@ -136,11 +136,13 @@ android_library { "src/com/android/launcher3/widgetpicker/ui/components/Strings.kt", "src/com/android/launcher3/widgetpicker/ui/components/WidgetDetails.kt", "src/com/android/launcher3/widgetpicker/ui/components/WidgetPreview.kt", + "src/com/android/launcher3/widgetpicker/ui/components/DragAndDrop.kt", "src/com/android/launcher3/widgetpicker/ui/components/WidgetPreviewHostView.kt", "src/com/android/launcher3/widgetpicker/ui/components/WidgetsGrid.kt", "src/com/android/launcher3/widgetpicker/ui/components/ExpandCollapseIndicator.kt", "src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsListHeader.kt", "src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsList.kt", + "src/com/android/launcher3/widgetpicker/ui/components/TestTag.kt", ], static_libs: [ "widget_picker_event_listeners", diff --git a/modules/widgetpicker/res/values/strings.xml b/modules/widgetpicker/res/values/strings.xml index fbd965e019..efb67cdc93 100644 --- a/modules/widgetpicker/res/values/strings.xml +++ b/modules/widgetpicker/res/values/strings.xml @@ -27,8 +27,21 @@ %1$d \u00d7 %2$d - + %1$d wide by %2$d high + + + %1$s widget, %2$d wide by %3$d high + + + Show add button + + Hide add button + + Add + + Add %1$s widget diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/shared/model/WidgetHostInfo.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/shared/model/WidgetHostInfo.kt index 6d48e5a67e..3622f9f9a2 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/shared/model/WidgetHostInfo.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/shared/model/WidgetHostInfo.kt @@ -25,11 +25,15 @@ import android.os.UserHandle * @param description an optional 1-2 line description to be shown below the title. If not set, no * description is shown. * @param constraints constraints around which widgets can be shown in the picker. + * @param showDragShadow indicates whether to show drag shadow for the widgets when dragging them; + * can be set to false if host manages drag shadow on its own (e.g. home screen to animate the + * shadow with actual content) */ data class WidgetHostInfo( val title: String? = null, val description: String? = null, val constraints: List = emptyList(), + val showDragShadow: Boolean = true ) /** Various constraints for the widget host. */ diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/DragAndDrop.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/DragAndDrop.kt new file mode 100644 index 0000000000..309eb0f863 --- /dev/null +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/DragAndDrop.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://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.android.launcher3.widgetpicker.ui.components + +import android.appwidget.AppWidgetProviderInfo +import android.content.ClipData +import android.content.ClipDescription +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Point +import android.graphics.Rect +import android.view.View +import android.view.View.DragShadowBuilder +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.core.graphics.drawable.RoundedBitmapDrawable +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory +import java.util.UUID + +/** + * Information about the image's dimensions post scaling. + */ +data class ImageScaledDimensions( + val scale: Float, + val scaledSizeDp: DpSize, + val scaledSizePx: IntSize, + val scaledRadiusDp: Dp, + val scaledRadiusPx: Float +) + +/** + * A [DragShadowBuilder] that draws drag shadow using the provided bitmap and image dimensions. + */ +class ImageBitmapDragShadowBuilder( + context: Context, + bitmap: Bitmap, + imageScaledDimensions: ImageScaledDimensions +) : DragShadowBuilder() { + private val shadowWidth = imageScaledDimensions.scaledSizePx.width + private val shadowHeight = imageScaledDimensions.scaledSizePx.height + + private val shadowDrawable: RoundedBitmapDrawable = + RoundedBitmapDrawableFactory.create(context.resources, bitmap) + .apply { cornerRadius = imageScaledDimensions.scaledRadiusPx } + + override fun onProvideShadowMetrics(outShadowSize: Point?, outShadowTouchPoint: Point?) { + outShadowSize?.set(shadowWidth, shadowHeight) + // Set the touch point's position to be in the middle of the drag shadow. + outShadowTouchPoint?.set(shadowWidth / 2, shadowHeight / 2) + } + + override fun onDrawShadow(canvas: Canvas) { + // The Drawable's native bounds may be different than the source ImageView. Change it to + // to the needed size. + val oldBounds: Rect = shadowDrawable.copyBounds() + shadowDrawable.setBounds(0, 0, shadowWidth, shadowHeight) + canvas.let { shadowDrawable.draw(it) } + shadowDrawable.bounds = oldBounds + } +} + +/** + * A [DragShadowBuilder] that draws a transparent drag shadow; useful for cases when the actual drag + * shadow is displayed by the host. + */ +object TransparentDragShadowBuilder : DragShadowBuilder() { + private const val SHADOW_SIZE = 10 + + override fun onDrawShadow(canvas: Canvas) {} + override fun onProvideShadowMetrics(outShadowSize: Point, outShadowTouchPoint: Point) { + outShadowSize.set(SHADOW_SIZE, SHADOW_SIZE); + outShadowTouchPoint.set(SHADOW_SIZE / 2, SHADOW_SIZE / 2); + } +} + +/** State containing information to start a drag for a widget. */ +class DragState( + private val widgetInfo: AppWidgetProviderInfo, + private val dragShadowBuilder: DragShadowBuilder +) { + private val uniqueId = UUID.randomUUID().toString() + val pickerMimeType = "com.android.launcher3.widgetpicker.drag_and_drop/$uniqueId" + + fun startDrag(view: View) { + val clipData = ClipData( + ClipDescription( + // not displayed anywhere; so, set to empty. + /* label= */ "", + arrayOf( + // unique picker specific mime type. + pickerMimeType, + // indicates that the clip item contains an intent (with extras about widget + // info). + ClipDescription.MIMETYPE_TEXT_INTENT + ) + ), + ClipData.Item( + Intent() + .putExtra(Intent.EXTRA_USER, widgetInfo.profile) + .putExtra( + Intent.EXTRA_COMPONENT_NAME, + widgetInfo.provider + ) + ) + ) + + view.startDragAndDrop( + clipData, + /*shadowBuilder=*/ dragShadowBuilder, + /*myLocalState=*/ null, + View.DRAG_FLAG_GLOBAL + ) + } +} diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/TestTag.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/TestTag.kt new file mode 100644 index 0000000000..63f4d217ef --- /dev/null +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/TestTag.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://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.android.launcher3.widgetpicker.ui.components + +/** Builds a test tag prefixed with the widget picker package name. */ +fun widgetPickerTestTag(id: String): String = "com.android.launcher3.widgetpicker:id/$id" diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetAppIcon.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetAppIcon.kt index 62e10e56aa..735d8c1255 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetAppIcon.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetAppIcon.kt @@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap @@ -68,7 +67,7 @@ private fun HighResAppIcon( Box(modifier = Modifier.size(size.iconSize)) { Icon( bitmap = icon.bitmap.asImageBitmap(), - modifier = Modifier.fillMaxSize().clip(CircleShape), + modifier = Modifier.fillMaxSize(), contentDescription = null, tint = Color.Unspecified, ) diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetDetails.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetDetails.kt index b83d59fba6..12ab8d5723 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetDetails.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetDetails.kt @@ -16,20 +16,42 @@ package com.android.launcher3.widgetpicker.ui.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -39,6 +61,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.launcher3.widgetpicker.R import com.android.launcher3.widgetpicker.shared.model.PickableWidget +import com.android.launcher3.widgetpicker.shared.model.WidgetId +import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo +import com.android.launcher3.widgetpicker.ui.components.AddButtonDefaults.TOGGLE_ANIMATION_DURATION +import com.android.launcher3.widgetpicker.ui.components.WidgetDetailsDimensions.INVISIBLE_ALPHA +import com.android.launcher3.widgetpicker.ui.components.WidgetDetailsDimensions.VISIBLE_ALPHA /** * Displays the details of the widget that can be shown below their previews. @@ -48,6 +75,8 @@ import com.android.launcher3.widgetpicker.shared.model.PickableWidget * app's context e.g. in recommendations. * @param showAllDetails when set, besides the widget label, also shows widget spans and 1-3 line * long description + * @param showAddButton when set, displays the add button instead of details. + * @param onWidgetAddClick callback when user clicks on the add button to add the widget * @param modifier modifier for the top level composable. */ @Composable @@ -55,20 +84,118 @@ fun WidgetDetails( widget: PickableWidget, appIcon: (@Composable () -> Unit)?, showAllDetails: Boolean, + showAddButton: Boolean, + onWidgetAddClick: (WidgetInteractionInfo.WidgetAddInfo) -> Unit, + onAddButtonToggle: (WidgetId) -> Unit, modifier: Modifier, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - modifier = modifier.fillMaxSize().padding( - horizontal = WidgetDetailsDimension.horizontalPadding, - vertical = WidgetDetailsDimension.verticalPadding - ), + val haptic = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + val contentDescription = stringResource( + R.string.widget_details_accessibility_label, + widget.label, + widget.sizeInfo.spanX, + widget.sizeInfo.spanY + ) + + val detailsAlpha: Float by animateFloatAsState( + targetValue = if (showAddButton) INVISIBLE_ALPHA else VISIBLE_ALPHA, + animationSpec = tween(durationMillis = TOGGLE_ANIMATION_DURATION), + label = "detailsAlphaAnimation" + ) + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxSize() + .clickable( + onClickLabel = if (showAddButton) { + stringResource(R.string.widget_tap_to_hide_add_button_label) + } else { + stringResource(R.string.widget_tap_to_show_add_button_label) + }, + interactionSource = interactionSource, + indication = null + ) { + haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) + onAddButtonToggle( + widget.id + ) + } + .padding( + horizontal = WidgetDetailsDimensions.horizontalPadding, + vertical = WidgetDetailsDimensions.verticalPadding + ) ) { - WidgetLabel(label = widget.label, appIcon = appIcon, modifier = Modifier) - if (showAllDetails) { - WidgetSpanSizeLabel(spanX = widget.sizeInfo.spanX, spanY = widget.sizeInfo.spanY) - widget.description?.let { WidgetDescription(it) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .clearAndSetSemantics { this.contentDescription = contentDescription } + .minimumInteractiveComponentSize() + .graphicsLayer { alpha = detailsAlpha } + .fillMaxSize() + ) { + WidgetLabel( + label = widget.label, + appIcon = appIcon, + modifier = Modifier + ) + if (showAllDetails) { + WidgetSpanSizeLabel( + spanX = widget.sizeInfo.spanX, + spanY = widget.sizeInfo.spanY + ) + widget.description?.let { WidgetDescription(it) } + } + } + AnimatedVisibility( + visible = showAddButton, + modifier = Modifier.fillMaxSize(), + enter = AddButtonDefaults.enterTransition, + exit = AddButtonDefaults.exitTransition + ) { + AddButton( + widget = widget, + onClick = { + onWidgetAddClick( + WidgetInteractionInfo.WidgetAddInfo( + widget.appWidgetProviderInfo + ) + ) + haptic.performHapticFeedback(HapticFeedbackType.Confirm) + }, + ) + } + } +} + +@Composable +private fun AddButton( + widget: PickableWidget, + onClick: () -> Unit, +) { + val accessibleDescription = + stringResource(R.string.widget_tap_to_add_button_content_description, widget.label) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Button( + modifier = Modifier.minimumInteractiveComponentSize(), + contentPadding = AddButtonDimensions.paddingValues, + colors = AddButtonDefaults.colors, + onClick = onClick, + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null // decorative + ) + Text( + modifier = Modifier.semantics { this.contentDescription = accessibleDescription }, + text = stringResource(R.string.widget_tap_to_add_button_label) + ) } } } @@ -76,12 +203,18 @@ fun WidgetDetails( /** The label / short title of the widget provided by the developer in the manifest. */ @Composable private fun WidgetLabel(label: String, appIcon: (@Composable () -> Unit)?, modifier: Modifier) { - Row(modifier = modifier, horizontalArrangement = Arrangement.Center) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { if (appIcon != null) { appIcon() Spacer( modifier = - Modifier.width(WidgetDetailsDimension.appIconLabelSpacing).fillMaxHeight() + Modifier + .width(WidgetDetailsDimensions.appIconLabelSpacing) + .fillMaxHeight() ) } Text( @@ -136,8 +269,32 @@ private fun WidgetSpanSizeLabel(spanX: Int, spanY: Int) { ) } -private object WidgetDetailsDimension { +private object WidgetDetailsDimensions { val horizontalPadding: Dp = 4.dp val verticalPadding: Dp = 12.dp val appIconLabelSpacing = 8.dp + + const val VISIBLE_ALPHA = 1f + const val INVISIBLE_ALPHA = 0f +} + +private object AddButtonDimensions { + val paddingValues = PaddingValues( + start = 8.dp, + top = 11.dp, + end = 16.dp, + bottom = 11.dp + ) +} + +private object AddButtonDefaults { + const val TOGGLE_ANIMATION_DURATION = 400 + val enterTransition = fadeIn(animationSpec = tween(TOGGLE_ANIMATION_DURATION)) + val exitTransition = fadeOut(animationSpec = tween(TOGGLE_ANIMATION_DURATION)) + + val colors: ButtonColors + @Composable get() = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) } diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetPreview.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetPreview.kt index 9565fdd02f..f7d712cd0d 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetPreview.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetPreview.kt @@ -18,9 +18,16 @@ package com.android.launcher3.widgetpicker.ui.components import android.appwidget.AppWidgetProviderInfo import android.graphics.Bitmap +import android.graphics.Rect +import android.view.View +import android.view.View.DragShadowBuilder +import android.widget.FrameLayout import android.widget.RemoteViews import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width @@ -32,31 +39,51 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key +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.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.coerceAtMost +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import com.android.launcher3.widgetpicker.shared.model.WidgetId import com.android.launcher3.widgetpicker.shared.model.WidgetPreview import com.android.launcher3.widgetpicker.shared.model.WidgetSizeInfo +import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo +import kotlin.math.roundToInt /** Renders a different types of preview for an appwidget. */ @Composable fun WidgetPreview( + id: WidgetId, sizeInfo: WidgetSizeInfo, preview: WidgetPreview, appwidgetInfo: AppWidgetProviderInfo, modifier: Modifier = Modifier, + showDragShadow: Boolean, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + onAddButtonToggle: (WidgetId) -> Unit ) { + val interactionSource = remember { MutableInteractionSource() } + val haptic = LocalHapticFeedback.current + val widgetRadius = dimensionResource(android.R.dimen.system_app_widget_background_radius) val density = LocalDensity.current @@ -65,7 +92,17 @@ fun WidgetPreview( DpSize(sizeInfo.containerWidthPx.toDp(), sizeInfo.containerHeightPx.toDp()) } - Box(modifier = modifier.wrapContentSize()) { + Box( + modifier = modifier + .wrapContentSize() + .clickable( + interactionSource = interactionSource, + // no ripples for preview taps that toggle the add button. + indication = null + ) { + haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) + onAddButtonToggle(id) + }) { when (preview) { is WidgetPreview.PlaceholderWidgetPreview -> PlaceholderWidgetPreview(size = containerSize, widgetRadius = widgetRadius) @@ -75,6 +112,9 @@ fun WidgetPreview( bitmap = preview.bitmap, size = containerSize, widgetRadius = widgetRadius, + widgetInfo = appwidgetInfo, + showDragShadow = showDragShadow, + onWidgetInteraction = onWidgetInteraction, ) is WidgetPreview.RemoteViewsWidgetPreview -> @@ -83,6 +123,8 @@ fun WidgetPreview( widgetInfo = appwidgetInfo, sizeInfo = sizeInfo, widgetRadius = widgetRadius, + showDragShadow = showDragShadow, + onWidgetInteraction = onWidgetInteraction, ) is WidgetPreview.ProviderInfoWidgetPreview -> @@ -91,6 +133,8 @@ fun WidgetPreview( widgetInfo = appwidgetInfo, sizeInfo = sizeInfo, widgetRadius = widgetRadius, + showDragShadow = showDragShadow, + onWidgetInteraction = onWidgetInteraction, ) } } @@ -101,7 +145,8 @@ private fun PlaceholderWidgetPreview(size: DpSize, widgetRadius: Dp) { Box( contentAlignment = Alignment.Center, modifier = - Modifier.width(size.width) + Modifier + .width(size.width) .height(size.height) .background( color = MaterialTheme.colorScheme.secondaryContainer, @@ -113,54 +158,142 @@ private fun PlaceholderWidgetPreview(size: DpSize, widgetRadius: Dp) { } @Composable -private fun BitmapWidgetPreview(bitmap: Bitmap, size: DpSize, widgetRadius: Dp) { +private fun BitmapWidgetPreview( + bitmap: Bitmap, + size: DpSize, + widgetInfo: AppWidgetProviderInfo, + widgetRadius: Dp, + showDragShadow: Boolean, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, +) { + val context = LocalContext.current val density = LocalDensity.current + val haptic = LocalHapticFeedback.current - val imageScale by - remember(bitmap) { - derivedStateOf { - with(density) { - val bitmapHeight = bitmap.height.toDp() - val bitmapWidth = bitmap.width.toDp() - val bitmapAspectRatio = bitmapWidth / bitmapHeight - val containerAspectRatio: Float = size.width / size.height + val scaledBitmapDimensions by remember(bitmap, density, size) { + derivedStateOf { bitmap.calculateScaledDimensions(density, size, widgetRadius) } + } - // Scale by width if image has larger aspect ratio than the container else by - // height; and avoid cropping the previews. - if (bitmapAspectRatio > containerAspectRatio) { - size.width / bitmapWidth - } else { - size.height / bitmapHeight - } + val dragState by remember(widgetInfo, showDragShadow) { + derivedStateOf { + DragState( + widgetInfo, + if (showDragShadow) { + ImageBitmapDragShadowBuilder(context, bitmap, scaledBitmapDimensions) + } else { + TransparentDragShadowBuilder } - } - } - val imageSize by - remember(imageScale) { - derivedStateOf { - with(density) { - val bitmapHeight = bitmap.height.toDp() - val bitmapWidth = bitmap.width.toDp() - DpSize(bitmapWidth * imageScale, bitmapHeight * imageScale) - } - } - } - val scaledCornerRadius by - remember(imageScale) { - derivedStateOf { (widgetRadius * imageScale).coerceAtMost(widgetRadius) } + ) } + } + + var imagePositionInParent by remember { mutableStateOf(Offset.Zero) } + + // A view to start drag and drop; the compose drag and drop doesn't provide pre-drag hooks. + // So, we simulate a drag and drop with a backing view. + val dragView: View = remember(widgetInfo) { FrameLayout(context) } + AndroidView(factory = { dragView }) Image( bitmap = bitmap.asImageBitmap(), contentDescription = null, // only visual (widget details provides the readable info) contentScale = ContentScale.FillBounds, modifier = - Modifier.width(imageSize.width) - .height(imageSize.height) - .clip(shape = RoundedCornerShape(scaledCornerRadius)), + Modifier + .onGloballyPositioned { coordinates -> + imagePositionInParent = coordinates.positionInParent() + } + .pointerInput(bitmap) { + detectDragGesturesAfterLongPress( + onDrag = { change, _ -> change.consume() }, + onDragStart = { offset -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + + dragState.startDrag(dragView) + + val bounds = + calculateImageDragBounds( + scaledBitmapDimensions = scaledBitmapDimensions, + imagePositionInParent = imagePositionInParent, + offset = offset + ) + onWidgetInteraction( + WidgetInteractionInfo.WidgetDragInfo( + mimeType = dragState.pickerMimeType, + providerInfo = widgetInfo, + bounds = bounds, + widthPx = scaledBitmapDimensions.scaledSizePx.width, + heightPx = scaledBitmapDimensions.scaledSizePx.height, + previewInfo = WidgetPreview.BitmapWidgetPreview( + bitmap = bitmap, + ), + ) + ) + } + ) + } + .width(scaledBitmapDimensions.scaledSizeDp.width) + .height(scaledBitmapDimensions.scaledSizeDp.height) + .clip(shape = RoundedCornerShape(scaledBitmapDimensions.scaledRadiusDp)), ) } +/** Returns the visual bounds of image offset by the touch point represented by [offset]. */ +private fun calculateImageDragBounds( + scaledBitmapDimensions: ImageScaledDimensions, + imagePositionInParent: Offset, + offset: Offset +): Rect { + val bounds = Rect() + bounds.left = 0 + bounds.top = 0 + bounds.right = scaledBitmapDimensions.scaledSizePx.width + bounds.bottom = scaledBitmapDimensions.scaledSizePx.height + val xOffset: Int = + (imagePositionInParent.x - offset.x).roundToInt() + val yOffset: Int = + (imagePositionInParent.y - offset.y).roundToInt() + bounds.offset(xOffset, yOffset) + return bounds +} + +private fun Bitmap.calculateScaledDimensions( + density: Density, + size: DpSize, + widgetRadius: Dp +) = + with(density) { + val bitmapSize = DpSize(width = width.toDp(), height = height.toDp()) + val bitmapAspectRatio = bitmapSize.width / bitmapSize.height + val containerAspectRatio: Float = size.width / size.height + + // Scale by width if image has larger aspect ratio than the container else by + // height; and avoid cropping the previews. + val scale = if (bitmapAspectRatio > containerAspectRatio) { + size.width / bitmapSize.width + } else { + size.height / bitmapSize.height + } + + val scaledDpSize = DpSize( + width = bitmapSize.width * scale, + height = bitmapSize.height * scale + ) + val scaledPxSize = IntSize( + width = scaledDpSize.width.roundToPx(), + height = scaledDpSize.height.roundToPx() + ) + val scaledRadius = (widgetRadius * scale).coerceAtMost(widgetRadius).value.roundToInt().dp + + ImageScaledDimensions( + scale = scale, + scaledSizePx = scaledPxSize, + scaledSizeDp = scaledDpSize, + scaledRadiusDp = scaledRadius, + scaledRadiusPx = scaledRadius.toPx() + ) + } + @Composable private fun RemoteViewsWidgetPreview( remoteViews: RemoteViews? = null, @@ -168,22 +301,74 @@ private fun RemoteViewsWidgetPreview( widgetInfo: AppWidgetProviderInfo, sizeInfo: WidgetSizeInfo, widgetRadius: Dp, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + showDragShadow: Boolean, ) { val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val appWidgetHostView by - remember(sizeInfo) { - derivedStateOf { - WidgetPreviewHostView(context).apply { - setContainerSizePx( - IntSize(sizeInfo.containerWidthPx, sizeInfo.containerHeightPx) - ) - } + remember(sizeInfo, widgetInfo) { + derivedStateOf { + WidgetPreviewHostView(context).apply { + setContainerSizePx( + IntSize(sizeInfo.containerWidthPx, sizeInfo.containerHeightPx) + ) } } + } - key(sizeInfo) { + val dragState by remember { + derivedStateOf { + DragState( + widgetInfo = widgetInfo, + dragShadowBuilder = if (showDragShadow) { + DragShadowBuilder(appWidgetHostView) + } else { + TransparentDragShadowBuilder + } + ) + } + } + + key(appWidgetHostView) { AndroidView( - modifier = Modifier.wrapContentSize().clip(RoundedCornerShape(widgetRadius)), + modifier = Modifier + .pointerInput(appWidgetHostView) { + detectDragGesturesAfterLongPress( + onDrag = { change, _ -> change.consume() }, + onDragStart = { offset -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + dragState.startDrag(appWidgetHostView) + + onWidgetInteraction( + WidgetInteractionInfo.WidgetDragInfo( + mimeType = dragState.pickerMimeType, + providerInfo = widgetInfo, + bounds = appWidgetHostView.getDragBoundsForOffset(offset), + widthPx = appWidgetHostView.measuredWidth, + heightPx = appWidgetHostView.measuredHeight, + previewInfo = when { + remoteViews != null -> + WidgetPreview.RemoteViewsWidgetPreview( + remoteViews = remoteViews, + ) + + previewLayoutProviderInfo != null -> + WidgetPreview.ProviderInfoWidgetPreview( + providerInfo = previewLayoutProviderInfo + ) + + else -> + throw IllegalStateException("No preview during drag") + } + ) + ) + }, + ) + } + .wrapContentSize() + .clip(RoundedCornerShape(widgetRadius)), factory = { appWidgetHostView }, update = { view -> // if preview.remoteViews is null, initial layout will render. diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetPreviewHostView.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetPreviewHostView.kt index 34c6956e39..f1c44c873c 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetPreviewHostView.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetPreviewHostView.kt @@ -18,8 +18,10 @@ package com.android.launcher3.widgetpicker.ui.components import android.appwidget.AppWidgetHostView import android.content.Context +import android.graphics.Rect import android.view.MotionEvent import android.view.View +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.IntSize import kotlin.math.roundToInt @@ -30,6 +32,7 @@ import kotlin.math.roundToInt */ class WidgetPreviewHostView(context: Context) : AppWidgetHostView(context) { private var previewContainerSizePx: IntSize? = null + private var contentScale = 1f init { clipToPadding = false @@ -64,6 +67,7 @@ class WidgetPreviewHostView(context: Context) : AppWidgetHostView(context) { child.scaleX = scale child.scaleY = scale + contentScale = scale setMeasuredDimension( (scale * childWidth).roundToInt(), @@ -100,4 +104,22 @@ class WidgetPreviewHostView(context: Context) : AppWidgetHostView(context) { measureChild(child, widgetSpec, heightSpec) return Pair(child.measuredWidth.toFloat(), child.measuredHeight.toFloat()) } + + /** + * Returns visual bounds of this preview offset by the provided [offset] and considering the + * scale of preview. + */ + fun getDragBoundsForOffset(offset: Offset): Rect { + val width: Int = (measuredWidth) + val height: Int = (measuredHeight) + val bounds = Rect(0, 0, width, height) + + val xOffset: Int = + left - (offset.x * contentScale).toInt() + val yOffset: Int = + top - (offset.y * contentScale).toInt() + bounds.offset(xOffset, yOffset) + + return bounds + } } diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGrid.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGrid.kt index a47865e83f..640596007d 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGrid.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGrid.kt @@ -21,7 +21,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.layout.Layout @@ -30,6 +33,7 @@ import androidx.compose.ui.layout.Placeable import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp @@ -42,6 +46,7 @@ import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon import com.android.launcher3.widgetpicker.shared.model.WidgetAppId import com.android.launcher3.widgetpicker.shared.model.WidgetId import com.android.launcher3.widgetpicker.shared.model.WidgetPreview +import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo import com.android.launcher3.widgetpicker.ui.components.WidgetGridDimensions.MAX_ITEMS_PER_ROW import com.android.launcher3.widgetpicker.ui.model.WidgetSizeGroup import kotlin.math.max @@ -55,6 +60,11 @@ import kotlin.math.max * label. * @param appIcons optional map containing app icons to show in the widget details besides the label * (when showing the widgets outside of app context e.g. recommendations) + * @param showDragShadow indicates if in a drag and drop session, widget picker should show drag + * shadow containing the preview; if not set, a transparent shadow is rendered and host should + * manage providing a shadow on its own. + * @param onWidgetInteraction callback invoked when a widget is being dragged and picker has started + * global drag and drop session. * @param modifier modifier with parent constraints and additional modifications */ @Composable @@ -64,7 +74,11 @@ fun WidgetsGrid( previews: Map, modifier: Modifier, appIcons: Map = emptyMap(), + showDragShadow: Boolean, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, ) { + var addButtonWidgetId by remember { mutableStateOf(null) } + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.padding(vertical = WidgetGridDimensions.gridVerticalPadding), @@ -75,6 +89,16 @@ fun WidgetsGrid( showAllWidgetDetails = showAllWidgetDetails, appIcons = appIcons, previews = previews, + showDragShadow = showDragShadow, + addButtonWidgetId = addButtonWidgetId, + onWidgetInteraction = onWidgetInteraction, + onAddButtonToggle = { id -> + addButtonWidgetId = if (id != addButtonWidgetId) { + id + } else { + null + } + } ) } } @@ -102,8 +126,12 @@ fun WidgetsGrid( private fun WidgetsFlowRow( widgetSizeGroup: WidgetSizeGroup, showAllWidgetDetails: Boolean, + addButtonWidgetId: WidgetId?, appIcons: Map, previews: Map, + showDragShadow: Boolean, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + onAddButtonToggle: (WidgetId) -> Unit, cellHorizontalPadding: Dp = WidgetGridDimensions.cellHorizontalPadding, rowVerticalSpacing: Dp = WidgetGridDimensions.rowVerticalSpacing, minItemWidth: Dp = WidgetGridDimensions.minItemWidth, @@ -111,8 +139,25 @@ private fun WidgetsFlowRow( val items = widgetSizeGroup.widgets WidgetsFlowRowLayout( - widgetPreviews = { Previews(items, previews) }, - widgetDetails = { Details(showAllWidgetDetails, items, appIcons) }, + widgetPreviews = { + Previews( + widgets = items, + previews = previews, + showDragShadow = showDragShadow, + onWidgetInteraction = onWidgetInteraction, + onAddButtonToggle = onAddButtonToggle, + ) + }, + widgetDetails = { + Details( + showAllWidgetDetails = showAllWidgetDetails, + widgets = items, + appIcons = appIcons, + addButtonWidgetId = addButtonWidgetId, + onWidgetInteraction = onWidgetInteraction, + onAddButtonToggle = onAddButtonToggle + ) + }, previewContainerWidthPx = widgetSizeGroup.previewContainerWidthPx, cellHorizontalPadding = cellHorizontalPadding, rowVerticalSpacing = rowVerticalSpacing, @@ -121,7 +166,13 @@ private fun WidgetsFlowRow( } @Composable -private fun Previews(widgets: List, previews: Map) { +private fun Previews( + widgets: List, + previews: Map, + showDragShadow: Boolean, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + onAddButtonToggle: (WidgetId) -> Unit, +) { widgets.forEachIndexed { index, widgetItem -> val id = widgetItem.id @@ -133,12 +184,21 @@ private fun Previews(widgets: List, previews: Map, previews: Map, + addButtonWidgetId: WidgetId?, appIcons: Map, + onWidgetInteraction: (WidgetInteractionInfo) -> Unit, + onAddButtonToggle: (WidgetId) -> Unit ) { widgets.forEachIndexed { index, widgetItem -> val appId = widgetItem.appId @@ -159,7 +222,10 @@ private fun Details( WidgetDetails( widget = widgetItem, showAllDetails = showAllWidgetDetails, + showAddButton = addButtonWidgetId == widgetItem.id, appIcon = appIcon, + onWidgetAddClick = onWidgetInteraction, + onAddButtonToggle = onAddButtonToggle, modifier = Modifier.semantics(mergeDescendants = true) { traversalIndex = index.toFloat() }, ) @@ -310,8 +376,8 @@ private fun Placeable.PlacementScope.placeRows( // Move to next row yPosition += measuredRow.tallestPreviewHeight + - measuredRow.tallestDetailsHeight + - rowVerticalSpacingPx + measuredRow.tallestDetailsHeight + + rowVerticalSpacingPx } } @@ -377,3 +443,5 @@ private object WidgetGridDimensions { const val MAX_ITEMS_PER_ROW = 3 val minItemWidth = 100.dp } + +private val WIDGET_PREVIEW_TEST_TAG = widgetPickerTestTag("widget_preview") diff --git a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/FullWidgetsCatalogViewModel.kt b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/FullWidgetsCatalogViewModel.kt index 0851a0d599..c7bdc94c6d 100644 --- a/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/FullWidgetsCatalogViewModel.kt +++ b/modules/widgetpicker/src/com/android/launcher3/widgetpicker/ui/fullcatalog/FullWidgetsCatalogViewModel.kt @@ -45,6 +45,7 @@ class FullWidgetsCatalogViewModel @AssistedInject constructor( val title: String? = hostInfo.title val description: String? = hostInfo.description + val showDragShadow: Boolean = hostInfo.showDragShadow var activeScreen by mutableStateOf(Screen.LANDING) private set diff --git a/modules/widgetpicker/tests/Android.bp b/modules/widgetpicker/tests/Android.bp index e20eea3f43..1936e917f4 100644 --- a/modules/widgetpicker/tests/Android.bp +++ b/modules/widgetpicker/tests/Android.bp @@ -96,6 +96,7 @@ filegroup { "multivalentTests/src/com/android/launcher3/widgetpicker/repository/FakeWidgetAppIconsRepository.kt", "multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePaneTest.kt", "multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPaneTest.kt", + "multivalentTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetInteractionsTest.kt", ], } diff --git a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/TestUtils.kt b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/TestUtils.kt index 3f1c435398..d65670177c 100644 --- a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/TestUtils.kt +++ b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/TestUtils.kt @@ -19,11 +19,15 @@ package com.android.launcher3.widgetpicker import android.appwidget.AppWidgetProviderInfo import android.appwidget.AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN import android.content.ComponentName +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color import android.os.UserHandle import com.android.launcher3.widgetpicker.shared.model.PickableWidget import com.android.launcher3.widgetpicker.shared.model.WidgetApp import com.android.launcher3.widgetpicker.shared.model.WidgetAppId import com.android.launcher3.widgetpicker.shared.model.WidgetId +import com.android.launcher3.widgetpicker.shared.model.WidgetPreview import com.android.launcher3.widgetpicker.shared.model.WidgetSizeInfo import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfile import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfileType @@ -114,7 +118,10 @@ object TestUtils { appId = finalWidgetAppId, label = providerClassName, description = null, - appWidgetProviderInfo = AppWidgetProviderInfo().apply { widgetCategory = category }, + appWidgetProviderInfo = AppWidgetProviderInfo().apply { + widgetCategory = category + provider = ComponentName.createRelative(PACKAGE_NAME, providerClassName) + }, sizeInfo = WidgetSizeInfo( spanX = 2, @@ -128,4 +135,15 @@ object TestUtils { ), ) } + + fun createBitmapPreview( + width: Int = 200, + height: Int = 200, + color: Int = Color.RED, + ): WidgetPreview.BitmapWidgetPreview { + val bitmap: Bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + Canvas(bitmap).drawColor(color) + + return WidgetPreview.BitmapWidgetPreview(bitmap = bitmap) + } } diff --git a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetInteractionsTest.kt b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetInteractionsTest.kt new file mode 100644 index 0000000000..a4c5d2d998 --- /dev/null +++ b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetInteractionsTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://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.android.launcher3.widgetpicker.ui.components + +import android.platform.test.rule.AllowedDevices +import android.platform.test.rule.DeviceProduct +import android.platform.test.rule.LimitDevicesRule +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.isNotDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.launcher3.widgetpicker.TestUtils +import com.android.launcher3.widgetpicker.TestUtils.PERSONAL_TEST_APPS +import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo +import com.android.launcher3.widgetpicker.ui.model.WidgetSizeGroup +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Tests for widget interactions e.g. tap to add. */ +@RunWith(AndroidJUnit4::class) +@AllowedDevices(allowed = [DeviceProduct.CF_PHONE]) +class WidgetInteractionsTest { + @get:Rule + val limitDevicesRule = LimitDevicesRule() + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun tapPreview_andClickAdd() { + composeTestRule.setContent { TapToAddTestComposable() } + + composeTestRule.waitForIdle() + + // tap on preview for widget 1 + composeTestRule.onAllNodesWithTag(PREVIEW_TEST_TAG) + .assertCountEquals(2) + .onFirst() + .performClick() + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(WIDGET_ONE.label).isNotDisplayed() // label text not shown + composeTestRule.onAllNodesWithText(ADD_BUTTON_TEXT).assertCountEquals(1) + composeTestRule.onNodeWithContentDescription(WIDGET_ONE_ADD_BUTTON_CONTENT_DESC) + .assertExists() + .performClick() + + composeTestRule.waitForIdle() + + // widget interaction callback invoked and correct provider info returned. + composeTestRule.onNodeWithText(WIDGET_ONE.appWidgetProviderInfo.provider.toString()) + .assertExists() + + // tap again on preview for widget 1 + composeTestRule.onAllNodesWithTag(PREVIEW_TEST_TAG) + .assertCountEquals(2) + .onFirst() + .performClick() + + // No add button + composeTestRule.onAllNodesWithText(ADD_BUTTON_TEXT).assertCountEquals(0) + // label text shown + composeTestRule.onNodeWithText(WIDGET_ONE.label).isDisplayed() + } + + @Test + fun tapPreview_togglesAddButton_onlyOneShownAtATime() { + composeTestRule.setContent { TapToAddTestComposable() } + + composeTestRule.waitForIdle() + + // tap on preview for widget 1 + composeTestRule.onAllNodesWithTag(PREVIEW_TEST_TAG) + .assertCountEquals(2) + .onFirst() + .performClick() + + composeTestRule.waitForIdle() + + // widget 1 - label text not shown, widget 2 - label still shown + composeTestRule.onNodeWithText(WIDGET_ONE.label).isNotDisplayed() + composeTestRule.onNodeWithText(WIDGET_TWO.label).isNotDisplayed() + composeTestRule.onAllNodesWithText(ADD_BUTTON_TEXT).assertCountEquals(1) + composeTestRule.onNodeWithContentDescription(WIDGET_ONE_ADD_BUTTON_CONTENT_DESC) + .assertExists() + + // tap on preview for widget 2 + composeTestRule.onAllNodesWithTag(PREVIEW_TEST_TAG) + .assertCountEquals(2) + .onLast() + .performClick() + + composeTestRule.waitForIdle() + + // now, widget 1 - label text shown & widget 2 - label not shown + composeTestRule.onNodeWithText(WIDGET_ONE.label).isDisplayed() + composeTestRule.onNodeWithText(WIDGET_TWO.label).isNotDisplayed() + composeTestRule.onAllNodesWithText(ADD_BUTTON_TEXT).assertCountEquals(1) + composeTestRule.onNodeWithContentDescription(WIDGET_TWO_ADD_BUTTON_CONTENT_DESC) + .assertExists() + } + + + @Composable + fun TapToAddTestComposable() { + var provider by remember { mutableStateOf("invalid") } + + Column(modifier = Modifier.fillMaxSize()) { + Text(provider) + WidgetsGrid( + widgetSizeGroups = listOf(TEST_WIDGET_GROUP), + showAllWidgetDetails = false, + previews = PREVIEWS, + modifier = Modifier.weight(1f), + appIcons = emptyMap(), + showDragShadow = false, + onWidgetInteraction = { widgetInteractionInfo -> + if (widgetInteractionInfo is WidgetInteractionInfo.WidgetAddInfo) { + provider = widgetInteractionInfo.providerInfo.provider.toString() + } + }, + ) + } + } + + companion object { + private val WIDGET_ONE = PERSONAL_TEST_APPS[0].widgets[0] + private val WIDGET_TWO = PERSONAL_TEST_APPS[1].widgets[0] + + private val TEST_WIDGET_GROUP = WidgetSizeGroup( + previewContainerHeightPx = 200, + previewContainerWidthPx = 200, + widgets = listOf(WIDGET_ONE, WIDGET_TWO) + ) + + private val PREVIEWS = mapOf( + WIDGET_ONE.id to TestUtils.createBitmapPreview(), + WIDGET_TWO.id to TestUtils.createBitmapPreview() + ) + + private val PREVIEW_TEST_TAG = widgetPickerTestTag("widget_preview") + private const val ADD_BUTTON_TEXT = "Add" + private val WIDGET_ONE_ADD_BUTTON_CONTENT_DESC = "Add ${WIDGET_ONE.label} widget" + private val WIDGET_TWO_ADD_BUTTON_CONTENT_DESC = "Add ${WIDGET_TWO.label} widget" + } +} diff --git a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePaneTest.kt b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePaneTest.kt index a0f49512d8..6a7b0020df 100644 --- a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePaneTest.kt +++ b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePaneTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasTextExactly import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -144,8 +145,10 @@ class LandingScreenSinglePaneTest { composeTestRule.onNode(hasTextExactly(PERSONAL_LABEL)).assertExists().assertIsNotSelected() composeTestRule.onNode(hasTextExactly(WORK_LABEL)).assertExists().assertIsNotSelected() // Featured Widgets state - composeTestRule.onNode(hasTextExactly(featuredWidgetA.label)).assertExists() - composeTestRule.onNode(hasTextExactly(featuredWidgetB.label)).assertExists() + composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true)) + .assertExists() + composeTestRule.onNode(hasContentDescription(featuredWidgetB.label, substring = true)) + .assertExists() } @OptIn(ExperimentalCoroutinesApi::class) @@ -166,7 +169,8 @@ class LandingScreenSinglePaneTest { .assertIsNotSelected() composeTestRule.onNode(hasTextExactly(WORK_LABEL)).assertExists().assertIsNotSelected() // No recommendations showing - composeTestRule.onNode(hasTextExactly(featuredWidgetA.label)).assertIsNotDisplayed() + composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true)) + .assertIsNotDisplayed() // Has list of personal apps showing composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[0].title!!.toString())) .assertExists() @@ -199,7 +203,8 @@ class LandingScreenSinglePaneTest { composeTestRule.onNode(hasTextExactly(browseTabLabel)).assertExists().assertIsNotSelected() composeTestRule.onNode(hasTextExactly(browseTabLabel)).onSiblings().assertCountEquals(1) // featured widgets showing - composeTestRule.onNode(hasTextExactly(featuredWidgetA.label)).assertIsDisplayed() + composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true)) + .assertIsDisplayed() // Widget apps list is not showing. composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[0].title!!.toString())) .assertIsNotDisplayed() diff --git a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPaneTest.kt b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPaneTest.kt index b445504054..f772fb7268 100644 --- a/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPaneTest.kt +++ b/modules/widgetpicker/tests/multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPaneTest.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasTextExactly import androidx.compose.ui.test.isSelected @@ -68,6 +69,7 @@ import org.junit.runner.RunWith class LandingScreenTwoPaneTest { @get:Rule val limitDevicesRule = LimitDevicesRule() + @get:Rule val composeTestRule = createAndroidComposeRule() @@ -146,8 +148,10 @@ class LandingScreenTwoPaneTest { composeTestRule.onNode(hasTextExactly(PERSONAL_LABEL)).assertExists().assertIsSelected() composeTestRule.onNode(hasTextExactly(WORK_LABEL)).assertExists().assertIsNotSelected() // Featured Widgets state - composeTestRule.onNode(hasTextExactly(featuredWidgetA.label)).assertExists() - composeTestRule.onNode(hasTextExactly(featuredWidgetB.label)).assertExists() + composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true)) + .assertExists() + composeTestRule.onNode(hasContentDescription(featuredWidgetB.label, substring = true)) + .assertExists() // List on left showing personal apps composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[0].title!!.toString())) .assertExists().assertIsNotSelected() @@ -177,7 +181,8 @@ class LandingScreenTwoPaneTest { // to click on specific app header to see the widgets. composeTestRule.onNode(hasText(featuredTabLabel)).assertIsSelected() // No recommendations showing - composeTestRule.onNode(hasText(featuredWidgetA.label)).assertIsDisplayed() + composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true)) + .assertIsDisplayed() // Has list of work apps showing composeTestRule.onNode(hasText(WORK_TEST_APPS[0].title!!.toString())) .assertExists() @@ -215,7 +220,12 @@ class LandingScreenTwoPaneTest { composeTestRule.onNode(hasText(appToSelect)).assertIsSelected() composeTestRule.onNode(hasText(featuredTabLabel)).assertIsNotSelected() // widgets for the selected app are showing - composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[1].widgets[0].label)) + composeTestRule.onNode( + hasContentDescription( + PERSONAL_TEST_APPS[1].widgets[0].label, + substring = true + ) + ) .assertIsDisplayed() val rightPaneTitleAfter = context.resources.getString( R.string.widget_picker_right_pane_accessibility_label, @@ -250,11 +260,16 @@ class LandingScreenTwoPaneTest { // No toolbar i.e. browse tab composeTestRule.onNode(hasTextExactly(browseTabLabel)).assertDoesNotExist() // featured widgets showing - composeTestRule.onNode(hasTextExactly(featuredWidgetA.label)) + composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true)) .assertExists() .assertIsDisplayed() // But not other widgets - composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[1].widgets[0].label)) + composeTestRule.onNode( + hasContentDescription( + PERSONAL_TEST_APPS[1].widgets[0].label, + substring = true + ) + ) .assertDoesNotExist() // Widget apps list shown on left composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[0].title!!.toString())) From 622eb1d0431d77d1935e30a2c2da6fb060add1ff Mon Sep 17 00:00:00 2001 From: Shamali Patwa Date: Fri, 30 May 2025 08:13:45 -0700 Subject: [PATCH 4/4] Listen to and handle drag and drop for homescreen and lockscreen widgets in widget picker activity wrapper. Bug: 408283627 Flag: com.android.launcher3.enable_widget_picker_refactor Test: See demo in comments. Change-Id: Idcc94d35ca02cb31ce191bc99b5e21f5f0a395c4 --- .../WidgetPickerComposeWrapper.kt | 14 +- .../WidgetPickerComposeWrapperImpl.kt | 138 ++++++++++++++++-- .../widgetpicker/WidgetPickerActivity.kt | 2 +- .../widgetpicker/WidgetPickerConfig.kt | 19 +++ 4 files changed, 156 insertions(+), 17 deletions(-) diff --git a/compose/facade/core/widgetpicker/WidgetPickerComposeWrapper.kt b/compose/facade/core/widgetpicker/WidgetPickerComposeWrapper.kt index 452d8881e5..93bf32a83e 100644 --- a/compose/facade/core/widgetpicker/WidgetPickerComposeWrapper.kt +++ b/compose/facade/core/widgetpicker/WidgetPickerComposeWrapper.kt @@ -17,6 +17,8 @@ package com.android.launcher3.compose.core.widgetpicker import com.android.launcher3.widgetpicker.WidgetPickerActivity +import com.android.launcher3.widgetpicker.WidgetPickerConfig +import javax.annotation.Nonnull import javax.inject.Inject /** @@ -24,7 +26,11 @@ import javax.inject.Inject * widget picker in [WidgetPickerActivity] when compose is enabled via build flag. */ interface WidgetPickerComposeWrapper { - fun showAllWidgets(activity: WidgetPickerActivity) + fun showAllWidgets( + activity: WidgetPickerActivity, + @Nonnull + widgetPickerConfig: WidgetPickerConfig + ) } /** @@ -32,7 +38,11 @@ interface WidgetPickerComposeWrapper { * don't involve widget picker e.g. launcher preview OR when compose is disabled via build flag. */ class NoOpWidgetPickerComposeWrapper @Inject constructor() : WidgetPickerComposeWrapper { - override fun showAllWidgets(activity: WidgetPickerActivity) { + override fun showAllWidgets( + activity: WidgetPickerActivity, + @Nonnull + widgetPickerConfig: WidgetPickerConfig + ) { error("Widget picker with compose is not supported") } } diff --git a/compose/features/com/android/launcher3/widgetpicker/WidgetPickerComposeWrapperImpl.kt b/compose/features/com/android/launcher3/widgetpicker/WidgetPickerComposeWrapperImpl.kt index d4d67edb55..c441d053c1 100644 --- a/compose/features/com/android/launcher3/widgetpicker/WidgetPickerComposeWrapperImpl.kt +++ b/compose/features/com/android/launcher3/widgetpicker/WidgetPickerComposeWrapperImpl.kt @@ -16,25 +16,32 @@ package com.android.launcher3.widgetpicker +import android.app.Activity.RESULT_OK import android.content.Context +import android.content.Intent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalView +import com.android.launcher3.Launcher import com.android.launcher3.R -import com.android.launcher3.widgetpicker.WidgetPickerActivity import com.android.launcher3.compose.ComposeFacade import com.android.launcher3.compose.core.widgetpicker.WidgetPickerComposeWrapper import com.android.launcher3.concurrent.annotations.BackgroundContext import com.android.launcher3.dagger.ApplicationContext -import com.android.launcher3.widgetpicker.WidgetPickerComponent -import com.android.launcher3.widgetpicker.WidgetPickerEventListeners +import com.android.launcher3.util.ApiWrapper +import com.android.launcher3.widgetpicker.WidgetPickerConfig.Companion.EXTRA_IS_PENDING_WIDGET_DRAG +import com.android.launcher3.widgetpicker.WidgetPickerConfig.Companion.asHostConstraints import com.android.launcher3.widgetpicker.data.repository.WidgetAppIconsRepository import com.android.launcher3.widgetpicker.data.repository.WidgetUsersRepository import com.android.launcher3.widgetpicker.data.repository.WidgetsRepository +import com.android.launcher3.widgetpicker.listeners.WidgetPickerAddItemListener +import com.android.launcher3.widgetpicker.listeners.WidgetPickerDragItemListener import com.android.launcher3.widgetpicker.shared.model.WidgetHostInfo +import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo +import com.android.launcher3.widgetpicker.ui.WidgetPickerEventListeners import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Provider @@ -42,7 +49,7 @@ import kotlin.coroutines.CoroutineContext /** * An helper that bootstraps widget picker UI (from [WidgetPickerComponent]) in to - * [WidgetPickerActivity]. + * [WidgetPickerActivity] when compose is available and widget picker refactor flags are on. * * Sets up the bindings necessary for widget picker component. */ @@ -55,19 +62,19 @@ class WidgetPickerComposeWrapperImpl @Inject constructor( private val backgroundContext: CoroutineContext, @ApplicationContext private val appContext: Context, + private val apiWrapper: ApiWrapper, ) : WidgetPickerComposeWrapper { + override fun showAllWidgets( activity: WidgetPickerActivity, + widgetPickerConfig: WidgetPickerConfig ) { - val widgetPickerComponent = newWidgetPickerComponent() - val callbacks = object : WidgetPickerEventListeners { - override fun onClose() { - activity.finish() - } - } + val widgetPickerComponent = newWidgetPickerComponent(widgetPickerConfig) + val callbacks = activity.buildEventListeners(widgetPickerConfig, apiWrapper) val fullWidgetsCatalog = widgetPickerComponent.getFullWidgetsCatalog() val composeView = ComposeFacade.initComposeView(activity.asContext()) as ComposeView + composeView.apply { setContent { val scope = rememberCoroutineScope() @@ -90,20 +97,27 @@ class WidgetPickerComposeWrapperImpl @Inject constructor( } } - activity.dragLayer?.addView(composeView) + checkNotNull(activity.dragLayer).addView(composeView) } - private fun newWidgetPickerComponent(): WidgetPickerComponent = - widgetPickerComponentProvider.get() + private fun newWidgetPickerComponent( + widgetPickerConfig: WidgetPickerConfig + ): WidgetPickerComponent { + return widgetPickerComponentProvider.get() .build( widgetsRepository = widgetsRepository, widgetUsersRepository = widgetUsersRepository, widgetAppIconsRepository = widgetAppIconsRepository, widgetHostInfo = WidgetHostInfo( - appContext.resources.getString(R.string.widget_button_text) + title = widgetPickerConfig.title + ?: appContext.resources.getString(R.string.widget_button_text), + description = widgetPickerConfig.description, + constraints = widgetPickerConfig.asHostConstraints(), + showDragShadow = !widgetPickerConfig.isForHomeScreen ), backgroundContext = backgroundContext ) + } private fun initializeRepositories() { widgetsRepository.initialize() @@ -116,4 +130,100 @@ class WidgetPickerComposeWrapperImpl @Inject constructor( widgetUsersRepository.cleanUp() widgetAppIconsRepository.cleanUp() } + + companion object { + private const val HOME_SCREEN_WIDGET_INTERACTION_REASON_STRING = + "WidgetPickerActivity.OnWidgetInteraction" + + private fun WidgetPickerActivity.buildEventListeners( + widgetPickerConfig: WidgetPickerConfig, + apiWrapper: ApiWrapper + ) = object : WidgetPickerEventListeners { + override fun onClose() { + finish() + } + + override fun onWidgetInteraction(widgetInteractionInfo: WidgetInteractionInfo) { + if (widgetPickerConfig.isForHomeScreen) { + handleWidgetInteractionForHomeScreen(widgetInteractionInfo, apiWrapper) + } else { + handleWidgetInteractionForExternalHost(widgetInteractionInfo) + } + } + } + + /** + * Handles communication with the home screen about the "add" and "drag" interactions on + * widgets within widget picker. + * + * For home screen, we register a listener that is called back when home screen is shown; + * - WidgetPickerDragItemListener: bootstraps the drag helper that displays the shadow and + * handles the drag until completion. + * - WidgetPickerAddItemListener: once launcher is shown, triggers the flow to add the + * widget to workspace. + */ + private fun WidgetPickerActivity.handleWidgetInteractionForHomeScreen( + interactionInfo: WidgetInteractionInfo, + apiWrapper: ApiWrapper + ) { + val interactionListener = when (interactionInfo) { + is WidgetInteractionInfo.WidgetDragInfo -> + WidgetPickerDragItemListener( + mimeType = interactionInfo.mimeType, + appWidgetProviderInfo = interactionInfo.providerInfo, + widgetPreview = interactionInfo.previewInfo, + previewRect = interactionInfo.bounds, + previewWidth = interactionInfo.widthPx + ) + + is WidgetInteractionInfo.WidgetAddInfo -> + WidgetPickerAddItemListener(interactionInfo.providerInfo) + } + Launcher.ACTIVITY_TRACKER.registerCallback( + interactionListener, + HOME_SCREEN_WIDGET_INTERACTION_REASON_STRING + ) + startActivity( + /*intent=*/ Intent(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_HOME) + .setPackage(packageName) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + /*options=*/ apiWrapper.createFadeOutAnimOptions().toBundle() + ) + finish() + } + + /** + * Handles communication with the external host about the "add" and "drag" interactions on + * widgets within widget picker. + * + * - In case of drag and drop, finishes the activity with result indicating that there is a + * pending drag [EXTRA_IS_PENDING_WIDGET_DRAG] (that would contain the widget info as part + * of clip data) that the host should be handling. + * - In case of add, finishes the activity with result containing extra information about + * the widget being added (namely [Intent.EXTRA_COMPONENT_NAME] and [Intent.EXTRA_USER]. + */ + private fun WidgetPickerActivity.handleWidgetInteractionForExternalHost( + widgetInteractionInfo: WidgetInteractionInfo, + ) { + when (widgetInteractionInfo) { + is WidgetInteractionInfo.WidgetDragInfo -> + setResult( + RESULT_OK, + Intent().putExtra(EXTRA_IS_PENDING_WIDGET_DRAG, true) + ) + + is WidgetInteractionInfo.WidgetAddInfo -> { + val providerInfo = widgetInteractionInfo.providerInfo + setResult( + RESULT_OK, Intent() + .putExtra(Intent.EXTRA_COMPONENT_NAME, providerInfo.provider) + .putExtra(Intent.EXTRA_USER, providerInfo.profile) + ) + } + } + + finish() + } + } } diff --git a/src/com/android/launcher3/widgetpicker/WidgetPickerActivity.kt b/src/com/android/launcher3/widgetpicker/WidgetPickerActivity.kt index cc67fcd257..07144d87d5 100644 --- a/src/com/android/launcher3/widgetpicker/WidgetPickerActivity.kt +++ b/src/com/android/launcher3/widgetpicker/WidgetPickerActivity.kt @@ -50,7 +50,7 @@ open class WidgetPickerActivity : BaseActivity() { checkNotNull(_dragLayer).recreateControllers() if (Flags.enableWidgetPickerRefactor() && isComposeAvailable()) { - component.widgetPickerComposeWrapper.showAllWidgets(this) + component.widgetPickerComposeWrapper.showAllWidgets(this, widgetPickerConfig) } } diff --git a/src/com/android/launcher3/widgetpicker/WidgetPickerConfig.kt b/src/com/android/launcher3/widgetpicker/WidgetPickerConfig.kt index 2eeac52506..e056d604ce 100644 --- a/src/com/android/launcher3/widgetpicker/WidgetPickerConfig.kt +++ b/src/com/android/launcher3/widgetpicker/WidgetPickerConfig.kt @@ -17,6 +17,7 @@ package com.android.launcher3.widgetpicker import android.os.UserHandle +import com.android.launcher3.widgetpicker.shared.model.HostConstraint /** * Possible parameters sent over by the widget host when launching the widget picker activity. @@ -57,5 +58,23 @@ data class WidgetPickerConfig( const val EXTRA_IS_PENDING_WIDGET_DRAG = "is_pending_widget_drag" const val HOMESCREEN_WIDGETS_UI_SURFACE = "widgets" + + /** Builds the host constraints to provide to the widget picker module. */ + fun WidgetPickerConfig.asHostConstraints() = + buildList { + if (filteredUsers.isNotEmpty()) { + add(HostConstraint.HostUserConstraint(filteredUsers)) + } + if (categoryInclusionFilter != 0 + || categoryExclusionFilter != 0 + ) { + add( + HostConstraint.HostCategoryConstraint( + categoryInclusionMask = categoryInclusionFilter, + categoryExclusionMask = categoryExclusionFilter, + ) + ) + } + } } }