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/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/modules/widgetpicker/Android.bp b/modules/widgetpicker/Android.bp index 0342ea6e14..ac02aa6c73 100644 --- a/modules/widgetpicker/Android.bp +++ b/modules/widgetpicker/Android.bp @@ -136,13 +136,16 @@ 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", "widget_picker_ui_data_types", "androidx.compose.foundation_foundation", "androidx.compose.foundation_foundation-layout", @@ -162,6 +165,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 +192,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/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/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/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/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/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/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/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/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/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/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/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 851b7708c0..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 @@ -65,6 +66,7 @@ import org.junit.runner.RunWith class LandingScreenSinglePaneTest { @get:Rule val limitDevicesRule = LimitDevicesRule() + @get:Rule val composeTestRule = createAndroidComposeRule() @@ -123,6 +125,8 @@ class LandingScreenSinglePaneTest { LandingScreen( isCompact = true, onEnterSearchMode = {}, + onWidgetInteraction = {}, + showDragShadow = true, viewModel = viewModel, ) } @@ -141,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) @@ -163,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() @@ -196,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 f64008ddda..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() @@ -126,6 +128,8 @@ class LandingScreenTwoPaneTest { LandingScreen( isCompact = false, onEnterSearchMode = {}, + onWidgetInteraction = {}, + showDragShadow = true, viewModel = viewModel, ) } @@ -144,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() @@ -175,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() @@ -213,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, @@ -248,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())) 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 ) } 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) { 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, + ) + ) + } + } } }