Merge changes from topic "picker-a-dnd" into main
* changes: Listen to and handle drag and drop for homescreen and lockscreen widgets in widget picker activity wrapper. Implement tap-to-add and drag and drop in the compose widget picker Pass down the widget interaction callback and flag on whether to support drag shadow Add widget picker drag and add item listeners.
This commit is contained in:
committed by
Android (Google) Code Review
commit
9e604fbe6e
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+124
-14
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -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<Launcher> {
|
||||
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
|
||||
}
|
||||
}
|
||||
+87
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -122,6 +122,7 @@ dependencies {
|
||||
|
||||
// Shared testing libs
|
||||
testImplementation(project(":RobolectricLib"))
|
||||
testImplementation(project(":SharedTestLib"))
|
||||
androidTestImplementation(project(":SharedTestLib"))
|
||||
androidTestImplementation(project(":PlatformParameterizedLib"))
|
||||
androidTestImplementation(project(":ScreenshotLib"))
|
||||
|
||||
@@ -27,8 +27,21 @@
|
||||
<!-- The format string for the cell dimensions of a widget in the widget picker e.g. 2x2 -->
|
||||
<!-- There is a special version of this format string for Farsi (%1$dx%2$d) -->
|
||||
<string name="widget_span_dimensions_format">%1$d \u00d7 %2$d</string>
|
||||
<!-- Accessibility spoken message format for the cell dimensions of a widget in the widget picker -->
|
||||
<!-- Accessibility spoken message format for the cell dimensions of a widget in the widget picker [CHAR_LIMIT=none] -->
|
||||
<string name="widget_span_dimensions_accessible_format">%1$d wide by %2$d high</string>
|
||||
<!-- Accessibility spoken message format for the widget's details shown in the widget picker. [CHAR_LIMIT=none] -->
|
||||
<string name="widget_details_accessibility_label">
|
||||
<xliff:g id="widget_name" example="Calendar month view">%1$s</xliff:g> widget, %2$d wide by %3$d high
|
||||
</string>
|
||||
<!-- Accessibility label on the widget preview that on click (if add button is hidden) shows the button to add widget to the home screen. [CHAR_LIMIT=none] -->
|
||||
<string name="widget_tap_to_show_add_button_label">Show add button</string>
|
||||
<!-- Accessibility label on the widget preview that on click (if add button is showing) hides the button to add widget to the home screen. [CHAR_LIMIT=none] -->
|
||||
<string name="widget_tap_to_hide_add_button_label">Hide add button</string>
|
||||
<!-- Text on the button that adds a widget to the home screen. [CHAR_LIMIT=15] -->
|
||||
<string name="widget_tap_to_add_button_label">Add</string>
|
||||
<!-- Accessibility content description for the button that adds a widget to the home screen. The
|
||||
placeholder text is the widget name. [CHAR_LIMIT=none] -->
|
||||
<string name="widget_tap_to_add_button_content_description">Add <xliff:g id="widget_name" example="Calendar month view">%1$s</xliff:g> widget</string>
|
||||
|
||||
<!-- Tab / list header label. A user can tap this to access recommended (or featured) widgets.
|
||||
[CHAR_LIMIT=25] -->
|
||||
|
||||
+4
@@ -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<HostConstraint> = emptyList(),
|
||||
val showDragShadow: Boolean = true
|
||||
)
|
||||
|
||||
/** Various constraints for the widget host. */
|
||||
|
||||
+39
-1
@@ -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()
|
||||
}
|
||||
|
||||
+131
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
+1
-2
@@ -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,
|
||||
)
|
||||
|
||||
+12
-1
@@ -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<WidgetAppId, WidgetAppIcon>,
|
||||
widgetPreviews: Map<WidgetId, WidgetPreview>,
|
||||
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<WidgetId, WidgetPreview>,
|
||||
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
|
||||
|
||||
+171
-14
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+230
-45
@@ -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.
|
||||
|
||||
+22
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+74
-6
@@ -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<WidgetId, WidgetPreview>,
|
||||
modifier: Modifier,
|
||||
appIcons: Map<WidgetAppId, WidgetAppIcon> = emptyMap(),
|
||||
showDragShadow: Boolean,
|
||||
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
|
||||
) {
|
||||
var addButtonWidgetId by remember { mutableStateOf<WidgetId?>(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<WidgetAppId, WidgetAppIcon>,
|
||||
previews: Map<WidgetId, WidgetPreview>,
|
||||
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<PickableWidget>, previews: Map<WidgetId, WidgetPreview>) {
|
||||
private fun Previews(
|
||||
widgets: List<PickableWidget>,
|
||||
previews: Map<WidgetId, WidgetPreview>,
|
||||
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<PickableWidget>, previews: Map<WidgetId, Widg
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
modifier =
|
||||
Modifier.fillMaxSize().clearAndSetSemantics { traversalIndex = index.toFloat() },
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clearAndSetSemantics {
|
||||
traversalIndex = index.toFloat()
|
||||
testTag = WIDGET_PREVIEW_TEST_TAG
|
||||
},
|
||||
) {
|
||||
WidgetPreview(
|
||||
id = widgetItem.id,
|
||||
sizeInfo = widgetItem.sizeInfo,
|
||||
preview = widgetPreview,
|
||||
appwidgetInfo = widgetItem.appWidgetProviderInfo,
|
||||
showDragShadow = showDragShadow,
|
||||
onWidgetInteraction = onWidgetInteraction,
|
||||
onAddButtonToggle = onAddButtonToggle
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -148,7 +208,10 @@ private fun Previews(widgets: List<PickableWidget>, previews: Map<WidgetId, Widg
|
||||
private fun Details(
|
||||
showAllWidgetDetails: Boolean,
|
||||
widgets: List<PickableWidget>,
|
||||
addButtonWidgetId: WidgetId?,
|
||||
appIcons: Map<WidgetAppId, WidgetAppIcon>,
|
||||
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")
|
||||
|
||||
+6
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
|
||||
+21
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+39
-13
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+28
-7
@@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -138,8 +138,10 @@ private fun GridPreview(
|
||||
WidgetsGrid(
|
||||
widgetSizeGroups = groups,
|
||||
showAllWidgetDetails = true,
|
||||
showDragShadow = false,
|
||||
previews = previews,
|
||||
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
|
||||
onWidgetInteraction = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+179
@@ -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<ComponentActivity>()
|
||||
|
||||
@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"
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -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<ComponentActivity>()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+23
-6
@@ -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<ComponentActivity>()
|
||||
|
||||
@@ -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()))
|
||||
|
||||
+3
-1
@@ -114,7 +114,9 @@ class SearchScreenTest {
|
||||
SearchScreen(
|
||||
isCompact = isCompact,
|
||||
onExitSearchMode = {},
|
||||
viewModel = viewModel,
|
||||
onWidgetInteraction = {},
|
||||
showDragShadow = true,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T extends Context & ActivityContext> 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<T extends Context & ActivityContext> 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<T extends Context & ActivityContext> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -50,7 +50,7 @@ open class WidgetPickerActivity : BaseActivity() {
|
||||
checkNotNull(_dragLayer).recreateControllers()
|
||||
|
||||
if (Flags.enableWidgetPickerRefactor() && isComposeAvailable()) {
|
||||
component.widgetPickerComposeWrapper.showAllWidgets(this)
|
||||
component.widgetPickerComposeWrapper.showAllWidgets(this, widgetPickerConfig)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user