Merge changes from topic "picker-move" into main
* changes: Add a widget apps list and the header composable for widget picker. Add screenshot tests for the widgets grid in picker Implement a grid of widgets for displaying in widget picker Add widget preview composable for widget previews in picker Add details composable for details shown below widget previews in picker Add a temporary component to display app icon for widgets list Make title optional on the widget picker bottom sheet & update insets Add a search bar for widget picker in compose Add an option to show shadow on the floating toolbar. Add top level reusable layouts for widget picker
This commit is contained in:
committed by
Android (Google) Code Review
commit
4bf3b1a7d9
@@ -82,6 +82,20 @@ android_library {
|
||||
],
|
||||
}
|
||||
|
||||
android_library {
|
||||
name: "widget_picker_ui_data_types",
|
||||
sdk_version: "current",
|
||||
min_sdk_version: min_launcher3_sdk_version,
|
||||
srcs: [
|
||||
"src/com/android/launcher3/widgetpicker/ui/model/DisplayableWidgetApp.kt",
|
||||
"src/com/android/launcher3/widgetpicker/ui/model/WidgetSizeGroup.kt",
|
||||
],
|
||||
static_libs: [
|
||||
"androidx.compose.runtime_runtime",
|
||||
"widget_picker_shared_data_types",
|
||||
],
|
||||
}
|
||||
|
||||
android_library {
|
||||
name: "widget_picker_ui_components",
|
||||
sdk_version: "current",
|
||||
@@ -90,8 +104,20 @@ android_library {
|
||||
"src/com/android/launcher3/widgetpicker/ui/components/ScrollableFloatingToolbar.kt",
|
||||
"src/com/android/launcher3/widgetpicker/ui/components/LeadingIconToolbarTab.kt",
|
||||
"src/com/android/launcher3/widgetpicker/ui/components/TitledBottomSheet.kt",
|
||||
"src/com/android/launcher3/widgetpicker/ui/components/TwoPaneLayout.kt",
|
||||
"src/com/android/launcher3/widgetpicker/ui/components/SinglePaneLayout.kt",
|
||||
"src/com/android/launcher3/widgetpicker/ui/components/WidgetsSearchBar.kt",
|
||||
"src/com/android/launcher3/widgetpicker/ui/components/WidgetAppIcon.kt",
|
||||
"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/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",
|
||||
],
|
||||
static_libs: [
|
||||
"widget_picker_ui_data_types",
|
||||
"androidx.compose.foundation_foundation",
|
||||
"androidx.compose.foundation_foundation-layout",
|
||||
"androidx.compose.runtime_runtime",
|
||||
@@ -102,6 +128,11 @@ android_library {
|
||||
"androidx.compose.material_material-icons-extended",
|
||||
"androidx.activity_activity-compose",
|
||||
"widget_picker_window_size_class",
|
||||
"kotlinx-coroutines-android",
|
||||
"kotlinx-coroutines-core",
|
||||
],
|
||||
resource_dirs: [
|
||||
"res",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<!-- Placeholder text shown when app name is not loaded yet to display in the widget picker [CHAR_LIMIT=25] -->
|
||||
<string name="widgets_list_header_app_name_fallback_label">App name</string>
|
||||
<!-- Label for showing the number of widgets an app has in the full widgets picker.
|
||||
[CHAR_LIMIT=25] -->
|
||||
<string name="widgets_list_header_widgets_count_label">{count, plural,
|
||||
=1 {# widget}
|
||||
other {# widgets}
|
||||
}</string>
|
||||
|
||||
<!-- 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 -->
|
||||
<string name="widget_span_dimensions_accessible_format">%1$d wide by %2$d high</string>
|
||||
|
||||
<!-- Tab / list header label. A user can tap this to access recommended (or featured) widgets.
|
||||
[CHAR_LIMIT=25] -->
|
||||
<string name="featured_widgets_tab_label">Featured</string>
|
||||
|
||||
<!-- Search bar strings -->
|
||||
<!-- Placeholder text displayed in the widget's search bar [CHAR_LIMIT=50] -->
|
||||
<string name="widgets_search_bar_hint">Search</string>
|
||||
<!-- Accessibility label for the button to clear text in widgets search bar -->
|
||||
<string name="widget_search_bar_clear_button_label">Clear</string>
|
||||
<!-- Accessibility label for the button to go back from the search screen -->
|
||||
<string name="widget_search_bar_back_button_label">Back</string>
|
||||
</resources>
|
||||
+26
-1
@@ -36,6 +36,31 @@ data class PickableWidget(
|
||||
val id: WidgetId,
|
||||
val appId: WidgetAppId,
|
||||
val label: String,
|
||||
val description: String,
|
||||
val description: CharSequence?,
|
||||
val appWidgetProviderInfo: AppWidgetProviderInfo,
|
||||
val sizeInfo: WidgetSizeInfo,
|
||||
)
|
||||
|
||||
/**
|
||||
* Sizing information for a specific widget shown in a grid.
|
||||
*
|
||||
* @param spanX the number of horizontal cells in the host's grid that this widget takes
|
||||
* @param spanY the number of vertical cells in the host's grid that this widget takes
|
||||
* @param widthPx the width in pixels that the widget should ideally be sized at based on host's
|
||||
* grid
|
||||
* @param heightPx the height in pixels that the widget should ideally be sized at based on host's
|
||||
* grid
|
||||
* @param containerWidthPx the width of container in which the widget may need to be fit to; For
|
||||
* instance, for visual coherence, widgets of sizes like 3x2 are shown in 2x2 container based on a
|
||||
* predefined mapping logic. This allows us to show them in a single row when space permits.
|
||||
* [containerWidthPx] is the width in pixel for such a container. [containerHeightPx] is the
|
||||
* height in pixels for the container spans.
|
||||
*/
|
||||
data class WidgetSizeInfo(
|
||||
val spanX: Int,
|
||||
val spanY: Int,
|
||||
val widthPx: Int,
|
||||
val heightPx: Int,
|
||||
val containerWidthPx: Int,
|
||||
val containerHeightPx: Int,
|
||||
)
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowUp
|
||||
import androidx.compose.material3.Icon
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** A visual cue displayed on list headers to indicate its current expand / collapse state. */
|
||||
@Composable
|
||||
fun ExpandCollapseIndicator(
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
iconColor: Color = ExpandCollapseIndicatorDefaults.iconColor,
|
||||
backgroundColor: Color = ExpandCollapseIndicatorDefaults.backgroundColor,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
modifier
|
||||
.size(ExpandCollapseIndicatorDimensions.size)
|
||||
.clip(ExpandCollapseIndicatorDimensions.cornerShape)
|
||||
.background(backgroundColor),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(ExpandCollapseIndicatorDimensions.iconSize),
|
||||
tint = iconColor,
|
||||
contentDescription = null, // Decorative
|
||||
imageVector =
|
||||
if (expanded) {
|
||||
Icons.Rounded.KeyboardArrowUp
|
||||
} else {
|
||||
Icons.Rounded.KeyboardArrowDown
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object ExpandCollapseIndicatorDimensions {
|
||||
val cornerShape = RoundedCornerShape(50.dp)
|
||||
val size = 24.dp
|
||||
val iconSize = 16.dp
|
||||
}
|
||||
|
||||
private object ExpandCollapseIndicatorDefaults {
|
||||
val iconColor
|
||||
@Composable get() = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
|
||||
val backgroundColor
|
||||
@Composable get() = MaterialTheme.colorScheme.secondaryContainer
|
||||
}
|
||||
+2
-2
@@ -72,10 +72,10 @@ fun LeadingIconToolbarTab(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clip(CircleShape)
|
||||
.clickable { onClick() }
|
||||
.background(color = backgroundColor)
|
||||
.minimumInteractiveComponentSize()
|
||||
.padding(horizontal = LeadingIconToolbarTabDefaults.horizontalPadding)
|
||||
.clickable { onClick() },
|
||||
.padding(horizontal = LeadingIconToolbarTabDefaults.horizontalPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
|
||||
+17
-13
@@ -31,7 +31,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
@@ -59,20 +58,20 @@ import kotlinx.coroutines.launch
|
||||
*
|
||||
* This design is suitable for 2-3 tabs.
|
||||
*
|
||||
* @property tabs A list of tabs (typically [LeadingIconToolbarTab]) that can be arranged in a
|
||||
* @param tabs A list of tabs (typically [LeadingIconToolbarTab]) that can be arranged in a
|
||||
* scrollable row.
|
||||
* @property selectedTabIndex the tab that is currently selected; this enables bringing the tab into
|
||||
* @param selectedTabIndex the tab that is currently selected; this enables bringing the tab into
|
||||
* the visible area.
|
||||
* @property modifier additional modifications to be applied to the top level of the toolbar.
|
||||
* @property shape shape to clip the contents of the toolbar; defaults to fully rounded corners.
|
||||
* @property edgePadding padding applied horizontally on sides of the toolbar; in case of long
|
||||
* length tabs, visually the padding will appear only on left (in LTR for instance) and when you
|
||||
* scroll completely to right, the padding will appear on right; in case tab content is smaller in
|
||||
* @param modifier additional modifications to be applied to the top level of the toolbar.
|
||||
* @param shape shape to clip the contents of the toolbar; defaults to fully rounded corners.
|
||||
* @param edgePadding padding applied horizontally on sides of the toolbar; in case of long length
|
||||
* tabs, visually the padding will appear only on left (in LTR for instance) and when you scroll
|
||||
* completely to right, the padding will appear on right; in case tab content is smaller in
|
||||
* length, the padding appears on both sides.
|
||||
* @property maxWidth if the toolbar needs to be constraint to a specific width.
|
||||
* @property minTabWidth minimum width to be guaranteed for individual tabs
|
||||
* @property containerColor color to be applied to the surface of toolbar
|
||||
* @property scrollState the [ScrollState] of the toolbar's scrollable tab content
|
||||
* @param maxWidth if the toolbar needs to be constraint to a specific width.
|
||||
* @param minTabWidth minimum width to be guaranteed for individual tabs
|
||||
* @param containerColor color to be applied to the surface of toolbar
|
||||
* @param shadowElevation The size of the shadow below the surface.
|
||||
*/
|
||||
@Composable
|
||||
fun ScrollableFloatingToolbar(
|
||||
@@ -84,6 +83,7 @@ fun ScrollableFloatingToolbar(
|
||||
maxWidth: Dp = ScrollableFloatingToolbarDefaults.maxWidth,
|
||||
minTabWidth: Dp = ScrollableFloatingToolbarDefaults.minTabWidth,
|
||||
containerColor: Color = ScrollableFloatingToolbarDefaults.containerColor,
|
||||
shadowElevation: Dp = ScrollableFloatingToolbarDefaults.shadowElevation,
|
||||
) {
|
||||
check(tabs.size in 2..3) { "Unexpected number of tabs: ${tabs.size}. Suitable for 2-3 tabs." }
|
||||
|
||||
@@ -91,7 +91,9 @@ fun ScrollableFloatingToolbar(
|
||||
|
||||
Surface(
|
||||
color = containerColor,
|
||||
modifier = modifier.clip(shape).wrapContentSize(align = Alignment.Center),
|
||||
shadowElevation = shadowElevation,
|
||||
shape = shape,
|
||||
modifier = modifier.wrapContentSize(align = Alignment.Center),
|
||||
) {
|
||||
ScrollableTabsLayout(
|
||||
tabs = tabs,
|
||||
@@ -237,6 +239,8 @@ object ScrollableFloatingToolbarDefaults {
|
||||
val maxWidth: Dp = 348.dp
|
||||
val minTabWidth: Dp = 90.dp
|
||||
|
||||
val shadowElevation: Dp = 3.dp
|
||||
|
||||
val containerColor: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.surfaceBright
|
||||
}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.launcher3.widgetpicker.ui.components.SinglePaneLayoutDimensions.searchBarBottomMargin
|
||||
|
||||
/**
|
||||
* A layout that shows all the widget picker content in a column like pane.
|
||||
*
|
||||
* @param searchBar A sticky search bar shown on top in the left pane.
|
||||
* @param toolbar an option toolbar shown at bottom of the screen that allows users to select what
|
||||
* to see in the [content]
|
||||
* @param content the primary content e.g. widgets expand collapse list.
|
||||
*/
|
||||
@Composable
|
||||
fun SinglePaneLayout(
|
||||
searchBar: @Composable () -> Unit,
|
||||
toolbar: (@Composable () -> Unit)? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val topContent: @Composable ColumnScope.() -> Unit = {
|
||||
Column(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
searchBar()
|
||||
Spacer(modifier = Modifier.fillMaxWidth().height(searchBarBottomMargin))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
val bottomContent: @Composable ColumnScope.() -> Unit = { toolbar?.let { it() } }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
topContent()
|
||||
bottomContent()
|
||||
}
|
||||
}
|
||||
|
||||
private object SinglePaneLayoutDimensions {
|
||||
val searchBarBottomMargin = 16.dp
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.icu.text.MessageFormat
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.android.launcher3.widgetpicker.R
|
||||
import java.util.Locale
|
||||
|
||||
/** Helper to build a string representing widgets count */
|
||||
@Composable
|
||||
fun widgetsCountString(count: Int): String {
|
||||
val icuCountFormat =
|
||||
MessageFormat(
|
||||
stringResource(R.string.widgets_list_header_widgets_count_label),
|
||||
Locale.getDefault(),
|
||||
)
|
||||
return icuCountFormat.format(mapOf(COUNT_KEY to count))
|
||||
}
|
||||
|
||||
private const val COUNT_KEY = "count"
|
||||
+7
-8
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -59,7 +58,7 @@ import com.android.launcher3.widgetpicker.ui.windowsizeclass.isExtraTall
|
||||
* structure for different types of widget pickers.
|
||||
*
|
||||
* @param modifier modifier to be applies to the bottom sheet container.
|
||||
* @param title A top level title for the bottom sheet.
|
||||
* @param title A top level title for the bottom sheet. If title is absent, top header isn't shown.
|
||||
* @param description an optional short (1-2 line) description that can be shown below the title.
|
||||
* @param heightStyle indicates how much vertical space should the bottom sheet take; see
|
||||
* [ModalBottomSheetHeightStyle].
|
||||
@@ -72,7 +71,7 @@ import com.android.launcher3.widgetpicker.ui.windowsizeclass.isExtraTall
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun TitledBottomSheet(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
title: String?,
|
||||
description: String?,
|
||||
heightStyle: ModalBottomSheetHeightStyle,
|
||||
showDragHandle: Boolean = true,
|
||||
@@ -103,12 +102,12 @@ fun TitledBottomSheet(
|
||||
|
||||
ModalBottomSheet(
|
||||
sheetState = modalBottomSheetState,
|
||||
sheetGesturesEnabled = false,
|
||||
sheetMaxWidth = Dp.Unspecified,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
onDismissRequest = onDismissRequest,
|
||||
dragHandle = dragHandle,
|
||||
modifier =
|
||||
modifier.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.displayCutout)),
|
||||
modifier = modifier.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
@@ -116,8 +115,8 @@ fun TitledBottomSheet(
|
||||
.padding(horizontal = sheetInnerHorizontalPadding)
|
||||
.padding(top = sheetInnerTopPadding.takeIf { !showDragHandle } ?: 0.dp)
|
||||
) {
|
||||
Header(title = title, description = description)
|
||||
content()
|
||||
title?.let { Header(title = title, description = description) }
|
||||
Box(modifier = Modifier.windowInsetsPadding(WindowInsets.displayCutout)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,7 +160,7 @@ private fun Header(title: String, description: String?) {
|
||||
private object TitledBottomSheetDimens {
|
||||
val sheetInnerTopPadding = 16.dp
|
||||
val sheetInnerHorizontalPadding = 10.dp
|
||||
val headerBottomMargin = 24.dp
|
||||
val headerBottomMargin = 16.dp
|
||||
}
|
||||
|
||||
private object DragHandleDimens {
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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 androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.paneTitle
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayoutDimensions.LEFT_PANE_WEIGHT
|
||||
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayoutDimensions.RIGHT_PANE_WEIGHT
|
||||
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayoutDimensions.horizontalPadding
|
||||
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayoutDimensions.paneSpacing
|
||||
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayoutDimensions.searchBarBottomMargin
|
||||
|
||||
/**
|
||||
* A layout that splits the widget picker content into two panes where the left pane takes about
|
||||
* [LEFT_PANE_WEIGHT] space while the right pane takes the remaining.
|
||||
*
|
||||
* The left pane always has a [searchBar] on the top and the left and right panes contain dynamic
|
||||
* content based on the user's actions.
|
||||
*
|
||||
* @param searchBar A sticky search bar shown on top in the left pane.
|
||||
* @param leftContent list options available for user to select that will be shown on left
|
||||
* @param rightContent content for the currently selected list option
|
||||
* @param rightPaneTitle when a user selects an option on left pane, changing this title for right
|
||||
* pane guides the user that content for selected option is now visible on right. When using
|
||||
* accessibility services like talkback, after selecting an option on left, the users can use four
|
||||
* finger swipe down to move focus to the right pane.
|
||||
* @param rightPaneBackgroundColor color to use for that background of content on right.
|
||||
*/
|
||||
@Composable
|
||||
fun TwoPaneLayout(
|
||||
searchBar: @Composable () -> Unit,
|
||||
leftContent: @Composable () -> Unit,
|
||||
rightContent: @Composable () -> Unit,
|
||||
rightPaneTitle: String?,
|
||||
rightPaneBackgroundColor: Color = TwoPaneLayoutDefaults.rightPaneBackgroundColor,
|
||||
) {
|
||||
val rightPaneModifier =
|
||||
if (rightPaneTitle != null) {
|
||||
Modifier.semantics { paneTitle = rightPaneTitle }
|
||||
} else Modifier
|
||||
|
||||
val leftPane: @Composable RowScope.() -> Unit = {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.semantics { isTraversalGroup = true }
|
||||
.fillMaxHeight()
|
||||
.padding(end = paneSpacing)
|
||||
.weight(LEFT_PANE_WEIGHT)
|
||||
) {
|
||||
searchBar()
|
||||
Spacer(modifier = Modifier.height(searchBarBottomMargin).fillMaxWidth())
|
||||
leftContent()
|
||||
}
|
||||
}
|
||||
|
||||
val rightPane: @Composable RowScope.() -> Unit = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
rightPaneModifier
|
||||
.fillMaxHeight()
|
||||
.weight(RIGHT_PANE_WEIGHT)
|
||||
.clip(TwoPaneLayoutDimensions.rightPaneShape)
|
||||
.background(rightPaneBackgroundColor),
|
||||
) {
|
||||
rightContent()
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(horizontal = horizontalPadding).fillMaxSize()) {
|
||||
leftPane()
|
||||
rightPane()
|
||||
}
|
||||
}
|
||||
|
||||
private object TwoPaneLayoutDimensions {
|
||||
const val LEFT_PANE_WEIGHT = 0.37f
|
||||
const val RIGHT_PANE_WEIGHT = 1 - LEFT_PANE_WEIGHT
|
||||
|
||||
val horizontalPadding = 14.dp
|
||||
val paneSpacing = 16.dp
|
||||
|
||||
val searchBarBottomMargin = 16.dp
|
||||
|
||||
val rightPaneShape = RoundedCornerShape(28.dp)
|
||||
}
|
||||
|
||||
private object TwoPaneLayoutDefaults {
|
||||
val rightPaneBackgroundColor
|
||||
@Composable get() = MaterialTheme.colorScheme.surfaceBright
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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 androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
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
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.launcher3.widgetpicker.shared.model.AppIcon
|
||||
import com.android.launcher3.widgetpicker.shared.model.AppIconBadge
|
||||
import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon
|
||||
|
||||
/** An app icon rendered from the provided [WidgetAppIcon] with an option badge. */
|
||||
@Composable
|
||||
fun WidgetAppIcon(widgetAppIcon: WidgetAppIcon, size: AppIconSize) {
|
||||
val appIcon = widgetAppIcon.icon
|
||||
val badge = widgetAppIcon.badge
|
||||
|
||||
AnimatedContent(targetState = appIcon) { icon ->
|
||||
when (icon) {
|
||||
AppIcon.PlaceHolderAppIcon -> PlaceholderAppIcon(size)
|
||||
|
||||
is AppIcon.LowResColorIcon -> LowResAppIcon(size, icon)
|
||||
|
||||
is AppIcon.HighResBitmapIcon -> {
|
||||
HighResAppIcon(size, icon, badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HighResAppIcon(
|
||||
size: AppIconSize,
|
||||
icon: AppIcon.HighResBitmapIcon,
|
||||
badge: AppIconBadge,
|
||||
) {
|
||||
Box(modifier = Modifier.size(size.iconSize)) {
|
||||
Icon(
|
||||
bitmap = icon.bitmap.asImageBitmap(),
|
||||
modifier = Modifier.fillMaxSize().clip(CircleShape),
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
if (badge is AppIconBadge.DrawableBadge) {
|
||||
DrawableAppIconBadge(badge = badge, size = size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LowResAppIcon(size: AppIconSize, icon: AppIcon.LowResColorIcon) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(size.iconSize).background(color = Color(icon.color), shape = CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderAppIcon(size: AppIconSize) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(size.iconSize)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f),
|
||||
shape = CircleShape,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.DrawableAppIconBadge(badge: AppIconBadge.DrawableBadge, size: AppIconSize) {
|
||||
Icon(
|
||||
painter = painterResource(badge.drawableResId),
|
||||
modifier =
|
||||
Modifier.align(alignment = Alignment.BottomEnd)
|
||||
.size(size.badgeSize)
|
||||
.background(color = Color.White, shape = CircleShape)
|
||||
.shadow(elevation = 0.5.dp, shape = CircleShape, spotColor = Color(0x11000000)),
|
||||
contentDescription = null,
|
||||
tint = colorResource(badge.tintColor),
|
||||
)
|
||||
}
|
||||
|
||||
/** Size in which to display the app icon. */
|
||||
enum class AppIconSize(val iconSize: Dp, val badgeSize: Dp) {
|
||||
/** A large size app icon meant to be displayed in the list header. */
|
||||
MEDIUM(iconSize = 48.dp, badgeSize = 24.dp),
|
||||
|
||||
/** A small size app icon meant to be displayed along side the widget title / label. */
|
||||
SMALL(iconSize = 24.dp, badgeSize = 12.dp),
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* 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 androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.launcher3.widgetpicker.R
|
||||
import com.android.launcher3.widgetpicker.shared.model.AppIcon
|
||||
import com.android.launcher3.widgetpicker.shared.model.AppIconBadge
|
||||
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.model.DisplayableWidgetApp
|
||||
|
||||
/**
|
||||
* Displays a various apps on device that host widgets.
|
||||
*
|
||||
* Apps are displays as a header that user can select OR expand/collapse depending on the
|
||||
* [widgetAppHeaderStyle].
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetAppsList(
|
||||
widgetApps: List<DisplayableWidgetApp>,
|
||||
selectedWidgetAppId: WidgetAppId?,
|
||||
widgetAppHeaderStyle: WidgetAppHeaderStyle,
|
||||
modifier: Modifier,
|
||||
onWidgetAppClick: (DisplayableWidgetApp) -> Unit,
|
||||
appIcons: Map<WidgetAppId, WidgetAppIcon>,
|
||||
widgetPreviews: Map<WidgetId, WidgetPreview>,
|
||||
headerDescriptionStyle: AppHeaderDescriptionStyle = AppHeaderDescriptionStyle.WIDGETS_COUNT,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(WidgetAppsListDimensions.itemSpacing),
|
||||
modifier = modifier.clip(WidgetAppsListDimensions.largeShape),
|
||||
) {
|
||||
items(
|
||||
count = widgetApps.size,
|
||||
key = { index -> widgetApps[index].id.toString() },
|
||||
contentType = { widgetAppHeaderStyle },
|
||||
) { index ->
|
||||
val widgetApp = widgetApps[index]
|
||||
val selected = widgetApp.id == selectedWidgetAppId
|
||||
|
||||
val title = widgetApp.widgetHeaderTitle()
|
||||
val description = widgetApp.widgetHeaderDescription(headerDescriptionStyle)
|
||||
|
||||
val appIconForItem =
|
||||
remember(appIcons) {
|
||||
appIcons[widgetApp.id]
|
||||
?: WidgetAppIcon(AppIcon.PlaceHolderAppIcon, AppIconBadge.NoBadge)
|
||||
}
|
||||
val appIcon: @Composable () -> Unit =
|
||||
remember(appIconForItem) {
|
||||
{ WidgetAppIcon(widgetAppIcon = appIconForItem, size = AppIconSize.MEDIUM) }
|
||||
}
|
||||
|
||||
when (widgetAppHeaderStyle) {
|
||||
WidgetAppHeaderStyle.EXPANDABLE -> {
|
||||
ExpandableWidgetAppHeader(
|
||||
isFirst = index == 0,
|
||||
isLast = index == widgetApps.lastIndex,
|
||||
expanded = selected,
|
||||
widgetApp = widgetApp,
|
||||
appIcon = appIcon,
|
||||
title = title,
|
||||
description = description,
|
||||
widgetPreviews =
|
||||
if (selected) {
|
||||
widgetPreviews
|
||||
} else {
|
||||
emptyMap()
|
||||
},
|
||||
onWidgetAppClick = onWidgetAppClick,
|
||||
)
|
||||
}
|
||||
|
||||
WidgetAppHeaderStyle.CLICKABLE ->
|
||||
SelectableListHeader(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingAppIcon = appIcon,
|
||||
selected = selected,
|
||||
title = title,
|
||||
subTitle = description,
|
||||
shape = WidgetAppsListDimensions.largeShape,
|
||||
onSelect = { onWidgetAppClick(widgetApp) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandableWidgetAppHeader(
|
||||
isLast: Boolean,
|
||||
isFirst: Boolean,
|
||||
expanded: Boolean,
|
||||
widgetApp: DisplayableWidgetApp,
|
||||
appIcon: @Composable () -> Unit,
|
||||
title: String,
|
||||
description: String,
|
||||
widgetPreviews: Map<WidgetId, WidgetPreview>,
|
||||
onWidgetAppClick: (DisplayableWidgetApp) -> Unit,
|
||||
) {
|
||||
val expandedContent: @Composable () -> Unit =
|
||||
remember(widgetApp, widgetPreviews) {
|
||||
{
|
||||
WidgetsGrid(
|
||||
widgetSizeGroups = widgetApp.widgetSizeGroups,
|
||||
showAllWidgetDetails = true,
|
||||
previews = widgetPreviews,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceBright,
|
||||
shape =
|
||||
when {
|
||||
isLast -> WidgetAppsListDimensions.bottomLargeShape
|
||||
else -> WidgetAppsListDimensions.smallShape
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ExpandableListHeader(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
expanded = expanded,
|
||||
leadingAppIcon = appIcon,
|
||||
title = title,
|
||||
subTitle = description,
|
||||
expandedContent = expandedContent,
|
||||
onClick = { onWidgetAppClick(widgetApp) },
|
||||
shape =
|
||||
when {
|
||||
isFirst -> WidgetAppsListDimensions.topLargeShape
|
||||
isLast && !expanded -> WidgetAppsListDimensions.bottomLargeShape
|
||||
else -> WidgetAppsListDimensions.smallShape
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayableWidgetApp.widgetHeaderTitle(): String {
|
||||
return title?.toString() ?: stringResource(R.string.widgets_list_header_app_name_fallback_label)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayableWidgetApp.widgetHeaderDescription(style: AppHeaderDescriptionStyle): String {
|
||||
return when (style) {
|
||||
AppHeaderDescriptionStyle.WIDGETS_COUNT -> widgetsCountString(widgetsCount)
|
||||
|
||||
AppHeaderDescriptionStyle.COMBINED_WIDGETS_TITLE ->
|
||||
widgetSizeGroups.flatMap { it.widgets }.map { it.label }.joinToString { it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Type of app Header. */
|
||||
enum class WidgetAppHeaderStyle {
|
||||
// Clicking selects the item. Uses header background color to highlight that header is selected.
|
||||
CLICKABLE,
|
||||
|
||||
// Clicking expands the item. Uses a arrow icon at end to indicate that header is selected.
|
||||
EXPANDABLE,
|
||||
}
|
||||
|
||||
enum class AppHeaderDescriptionStyle {
|
||||
WIDGETS_COUNT,
|
||||
COMBINED_WIDGETS_TITLE,
|
||||
}
|
||||
|
||||
private object WidgetAppsListDimensions {
|
||||
val itemSpacing = 4.dp
|
||||
|
||||
val largeRadius = 24.dp
|
||||
val smallRadius = 4.dp
|
||||
|
||||
/** For entire list and clickable headers */
|
||||
val largeShape = RoundedCornerShape(largeRadius)
|
||||
|
||||
/** For first expandable item */
|
||||
val topLargeShape =
|
||||
RoundedCornerShape(
|
||||
topStart = largeRadius,
|
||||
topEnd = largeRadius,
|
||||
bottomStart = smallRadius,
|
||||
bottomEnd = smallRadius,
|
||||
)
|
||||
|
||||
/** For last expandable item -- when in collapsed state */
|
||||
val bottomLargeShape =
|
||||
RoundedCornerShape(
|
||||
topStart = smallRadius,
|
||||
topEnd = smallRadius,
|
||||
bottomStart = largeRadius,
|
||||
bottomEnd = largeRadius,
|
||||
)
|
||||
|
||||
/** For middle expandable items and last expandable item when in expanded state. */
|
||||
val smallShape = RoundedCornerShape(smallRadius)
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* 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 androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.launcher3.widgetpicker.R
|
||||
|
||||
/**
|
||||
* A list header in widget picker that when [expanded] displays the [expandedContent].
|
||||
*
|
||||
* Useful for single pane layouts where content is shown inline with the header on selection.
|
||||
*
|
||||
* @param modifier modifier for the top level composable of header
|
||||
* @param expanded whether to show the [expandedContent] below the header
|
||||
* @param leadingAppIcon an app icon shown in the beginning of the header row
|
||||
* @param title a short 1 line title for the header
|
||||
* @param subTitle a short 1 line description (e.g. number of items in the [expandedContent]).
|
||||
* @param expandedContent the content for the header when its selected
|
||||
* @param onClick action to perform on click; e.g. manage the expand / collapse state
|
||||
* @param shape shape for the header e.g. a different shape based on position in the list
|
||||
*/
|
||||
@Composable
|
||||
fun ExpandableListHeader(
|
||||
modifier: Modifier,
|
||||
expanded: Boolean,
|
||||
leadingAppIcon: @Composable () -> Unit,
|
||||
title: String,
|
||||
subTitle: String,
|
||||
expandedContent: @Composable () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
shape: RoundedCornerShape,
|
||||
) {
|
||||
val finalModifier =
|
||||
modifier
|
||||
.clip(shape = shape)
|
||||
.background(color = ExpandedListHeaderDefaults.backgroundColor)
|
||||
.clickable { onClick() }
|
||||
|
||||
Column(modifier = finalModifier) {
|
||||
WidgetAppHeader(
|
||||
modifier = Modifier,
|
||||
leadingIcon = { leadingAppIcon() },
|
||||
title = title,
|
||||
subTitle = subTitle,
|
||||
selected = expanded,
|
||||
trailingButton = { ExpandCollapseIndicator(expanded) },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = ExpandedListHeaderDefaults.contentExpandAnimationSpec,
|
||||
exit = ExpandedListHeaderDefaults.contentCollapseAnimationSpec,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
expandedContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A list header in widget picker that is selectable by clicking it.
|
||||
*
|
||||
* Useful for two pane layouts where content is shown in right pane while header is shown in left.
|
||||
*
|
||||
* @param modifier modifier for the top level composable of header
|
||||
* @param selected whether to show highlight the header's background to indicate its currently
|
||||
* selected.
|
||||
* @param leadingAppIcon an app icon shown in the beginning of the header row
|
||||
* @param title a short 1 line title for the header
|
||||
* @param subTitle a short 1 line description (e.g. number of widgets in the selected app).
|
||||
* @param onSelect action to perform when user clicks to select the header
|
||||
* @param shape shape for the header e.g. depending on position in the list, a different corner
|
||||
* @param selectedBackgroundColor background color when header is [selected]
|
||||
* @param unSelectedBackgroundColor background color when header is not [selected]
|
||||
*/
|
||||
@Composable
|
||||
fun SelectableListHeader(
|
||||
modifier: Modifier,
|
||||
selected: Boolean,
|
||||
leadingAppIcon: @Composable () -> Unit,
|
||||
title: String,
|
||||
subTitle: String,
|
||||
onSelect: () -> Unit,
|
||||
shape: RoundedCornerShape,
|
||||
selectedBackgroundColor: Color = ClickableListHeaderDefaults.selectedBackgroundColor,
|
||||
unSelectedBackgroundColor: Color = ClickableListHeaderDefaults.unSelectedBackgroundColor,
|
||||
) {
|
||||
val clickModifier =
|
||||
if (!selected) {
|
||||
Modifier.clickable { onSelect() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
WidgetAppHeader(
|
||||
modifier =
|
||||
modifier
|
||||
.semantics(mergeDescendants = true) { this.selected = selected }
|
||||
.clip(shape = shape)
|
||||
.background(
|
||||
color =
|
||||
if (selected) {
|
||||
selectedBackgroundColor
|
||||
} else {
|
||||
unSelectedBackgroundColor
|
||||
}
|
||||
)
|
||||
.then(clickModifier),
|
||||
leadingIcon = { leadingAppIcon() },
|
||||
title = title,
|
||||
subTitle = subTitle,
|
||||
selected = selected,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A selectable header that can be shown for suggested (featured) widgets option.
|
||||
*
|
||||
* @param modifier modifier for top level composable of the suggestions header.
|
||||
* @param selected if the header is currently selected.
|
||||
* @param count number of suggested widgets.
|
||||
* @param onSelect action to perform when user selects the header.
|
||||
* @param shape shape for the header e.g. depending on position in the list, a different corner.
|
||||
* @param selectedBackgroundColor background color when header is [selected].
|
||||
* @param unSelectedBackgroundColor background color when header is not [selected].
|
||||
*/
|
||||
@Composable
|
||||
fun SelectableSuggestionsHeader(
|
||||
modifier: Modifier,
|
||||
selected: Boolean,
|
||||
count: Int,
|
||||
onSelect: () -> Unit,
|
||||
shape: RoundedCornerShape,
|
||||
selectedBackgroundColor: Color = ClickableListHeaderDefaults.selectedBackgroundColor,
|
||||
unSelectedBackgroundColor: Color = ClickableListHeaderDefaults.unSelectedBackgroundColor,
|
||||
) {
|
||||
SelectableListHeader(
|
||||
modifier = modifier,
|
||||
selected = selected,
|
||||
shape = shape,
|
||||
selectedBackgroundColor = selectedBackgroundColor,
|
||||
unSelectedBackgroundColor = unSelectedBackgroundColor,
|
||||
title = stringResource(R.string.featured_widgets_tab_label),
|
||||
subTitle = widgetsCountString(count),
|
||||
leadingAppIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceBright)
|
||||
.minimumInteractiveComponentSize(),
|
||||
)
|
||||
},
|
||||
onSelect = {
|
||||
if (!selected) {
|
||||
onSelect()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetAppHeader(
|
||||
modifier: Modifier,
|
||||
leadingIcon: @Composable () -> Unit,
|
||||
title: String,
|
||||
subTitle: String,
|
||||
selected: Boolean,
|
||||
trailingButton: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
modifier
|
||||
.height(height = ListHeaderDimensions.headerHeight)
|
||||
.padding(horizontal = ListHeaderDimensions.headerHorizontalPadding),
|
||||
) {
|
||||
leadingIcon()
|
||||
CenterText(
|
||||
modifier =
|
||||
Modifier.weight(1f)
|
||||
.padding(horizontal = ListHeaderDimensions.centerTextHorizontalPadding),
|
||||
title = title,
|
||||
subTitle = subTitle,
|
||||
selected = selected,
|
||||
)
|
||||
trailingButton?.let { it() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterText(title: String, subTitle: String, selected: Boolean, modifier: Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = WidgetAppListHeaderDefaults.titleTextColor,
|
||||
style =
|
||||
if (selected) {
|
||||
WidgetAppListHeaderDefaults.selectedTitleTextStyle
|
||||
} else {
|
||||
WidgetAppListHeaderDefaults.unSelectedTitleTextStyle
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = subTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = WidgetAppListHeaderDefaults.subTitleTextColor,
|
||||
style =
|
||||
if (selected) {
|
||||
WidgetAppListHeaderDefaults.selectedSubTitleTextStyle
|
||||
} else {
|
||||
WidgetAppListHeaderDefaults.unSelectedSubTitleTextStyle
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object ListHeaderDimensions {
|
||||
val headerHeight = 80.dp
|
||||
val headerHorizontalPadding = 16.dp
|
||||
val centerTextHorizontalPadding = 16.dp
|
||||
}
|
||||
|
||||
private object ExpandedListHeaderDefaults {
|
||||
val backgroundColor: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.surfaceBright
|
||||
|
||||
val contentExpandAnimationSpec = fadeIn(tween(durationMillis = 500)) + expandVertically()
|
||||
val contentCollapseAnimationSpec = fadeOut(tween(durationMillis = 500)) + shrinkVertically()
|
||||
}
|
||||
|
||||
private object ClickableListHeaderDefaults {
|
||||
val selectedBackgroundColor
|
||||
@Composable get() = MaterialTheme.colorScheme.secondaryContainer
|
||||
|
||||
val unSelectedBackgroundColor
|
||||
@Composable get() = Color.Transparent
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
private object WidgetAppListHeaderDefaults {
|
||||
val selectedTitleTextStyle: TextStyle
|
||||
@Composable
|
||||
get() = MaterialTheme.typography.titleMediumEmphasized.copy(fontWeight = FontWeight.Medium)
|
||||
|
||||
val unSelectedTitleTextStyle: TextStyle
|
||||
@Composable
|
||||
get() = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal)
|
||||
|
||||
val selectedSubTitleTextStyle: TextStyle
|
||||
@Composable get() = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Medium)
|
||||
|
||||
val unSelectedSubTitleTextStyle: TextStyle
|
||||
@Composable get() = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Normal)
|
||||
|
||||
val titleTextColor: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
val subTitleTextColor: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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 androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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
|
||||
|
||||
/**
|
||||
* Displays the details of the widget that can be shown below their previews.
|
||||
*
|
||||
* @param widget the information about the widget that can be used to display the details
|
||||
* @param appIcon an optional app icon that can be displayed when widget is shown outside of the
|
||||
* 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 modifier modifier for the top level composable.
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetDetails(
|
||||
widget: PickableWidget,
|
||||
appIcon: (@Composable () -> Unit)?,
|
||||
showAllDetails: Boolean,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
modifier = modifier.fillMaxSize().padding(WidgetDetailsDimension.padding),
|
||||
) {
|
||||
WidgetLabel(label = widget.label, appIcon = appIcon, modifier = Modifier)
|
||||
if (showAllDetails) {
|
||||
WidgetSpanSizeLabel(spanX = widget.sizeInfo.spanX, spanY = widget.sizeInfo.spanY)
|
||||
widget.description?.let { WidgetDescription(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (appIcon != null) {
|
||||
appIcon()
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.width(WidgetDetailsDimension.appIconLabelSpacing).fillMaxHeight()
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a long description provided by the developers for the widget in their appwidget provider
|
||||
* info.
|
||||
*/
|
||||
@Composable
|
||||
private fun WidgetDescription(description: CharSequence) {
|
||||
Text(
|
||||
text = description.toString(),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 3,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Span (X and Y) sizing info for the widget. */
|
||||
@Composable
|
||||
private fun WidgetSpanSizeLabel(spanX: Int, spanY: Int) {
|
||||
val contentDescription =
|
||||
stringResource(R.string.widget_span_dimensions_accessible_format, spanX, spanY)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.widget_span_dimensions_format, spanX, spanY),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
modifier = Modifier.semantics { this.contentDescription = contentDescription },
|
||||
)
|
||||
}
|
||||
|
||||
private object WidgetDetailsDimension {
|
||||
val padding: Dp = 4.dp
|
||||
val appIconLabelSpacing = 8.dp
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.coerceAtMost
|
||||
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
|
||||
import com.android.launcher3.widgetpicker.shared.model.WidgetSizeInfo
|
||||
|
||||
/** Renders a different types of preview for an appwidget. */
|
||||
@Composable
|
||||
fun WidgetPreview(sizeInfo: WidgetSizeInfo, preview: WidgetPreview, modifier: Modifier = Modifier) {
|
||||
val widgetRadius = dimensionResource(android.R.dimen.system_app_widget_background_radius)
|
||||
|
||||
val density = LocalDensity.current
|
||||
val containerSize =
|
||||
with(density) {
|
||||
DpSize(sizeInfo.containerWidthPx.toDp(), sizeInfo.containerHeightPx.toDp())
|
||||
}
|
||||
|
||||
Box(modifier = modifier.wrapContentSize()) {
|
||||
when (preview) {
|
||||
is WidgetPreview.PlaceholderWidgetPreview ->
|
||||
PlaceholderWidgetPreview(size = containerSize, widgetRadius = widgetRadius)
|
||||
|
||||
is WidgetPreview.BitmapWidgetPreview ->
|
||||
BitmapWidgetPreview(
|
||||
bitmap = preview.bitmap,
|
||||
size = containerSize,
|
||||
widgetRadius = widgetRadius,
|
||||
)
|
||||
|
||||
// TODO(b/408283627): Add Remote Views preview.
|
||||
is WidgetPreview.RemoteViewsWidgetPreview ->
|
||||
PlaceholderWidgetPreview(size = containerSize, widgetRadius = widgetRadius)
|
||||
|
||||
// TODO(b/408283627): Add Generated previews.
|
||||
is WidgetPreview.ProviderInfoWidgetPreview ->
|
||||
PlaceholderWidgetPreview(size = containerSize, widgetRadius = widgetRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderWidgetPreview(size: DpSize, widgetRadius: Dp) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier.width(size.width)
|
||||
.height(size.height)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(widgetRadius),
|
||||
),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BitmapWidgetPreview(bitmap: Bitmap, size: DpSize, widgetRadius: Dp) {
|
||||
val density = LocalDensity.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
|
||||
|
||||
// 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 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) }
|
||||
}
|
||||
|
||||
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)),
|
||||
)
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
/*
|
||||
* 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 androidx.compose.foundation.layout.Box
|
||||
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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
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.traversalIndex
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFold
|
||||
import androidx.compose.ui.util.fastMaxOfOrDefault
|
||||
import androidx.compose.ui.util.fastSumBy
|
||||
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
|
||||
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.components.WidgetGridDimensions.MAX_ITEMS_PER_ROW
|
||||
import com.android.launcher3.widgetpicker.ui.model.WidgetSizeGroup
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Displays widgets with their previews and details organized as a grid.
|
||||
*
|
||||
* @param widgetSizeGroups group of widgets that use same preview size bucket (container) and hence
|
||||
* can be displayed side by side in a row for optimal previewing.
|
||||
* @param showAllWidgetDetails whether to show all details of each widget in the grid OR just show a
|
||||
* 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 modifier modifier with parent constraints and additional modifications
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetsGrid(
|
||||
widgetSizeGroups: List<WidgetSizeGroup>,
|
||||
showAllWidgetDetails: Boolean,
|
||||
previews: Map<WidgetId, WidgetPreview>,
|
||||
modifier: Modifier,
|
||||
appIcons: Map<WidgetAppId, WidgetAppIcon> = emptyMap(),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier.padding(vertical = WidgetGridDimensions.gridVerticalPadding),
|
||||
) {
|
||||
widgetSizeGroups.forEach { group ->
|
||||
WidgetsFlowRow(
|
||||
widgetSizeGroup = group,
|
||||
showAllWidgetDetails = showAllWidgetDetails,
|
||||
appIcons = appIcons,
|
||||
previews = previews,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom layout displaying similarly sized widgets in multiple rows.
|
||||
* - A key feature is the alignment of the top baseline of each widget's details section within a
|
||||
* row, regardless of preview size.
|
||||
* - Supports a maximum of [MAX_ITEMS_PER_ROW] per row.
|
||||
* - Row height adapts to the tallest element within it.
|
||||
*
|
||||
* Example visualization:
|
||||
* ```
|
||||
* xxxxxx xxxxxx
|
||||
* xxxxxx xxxxxxxx xxxxxx
|
||||
* xxxxxx xxxxxxxx xxxxxx <- Different preview heights
|
||||
* <title> <title> <title> <- Top baseline aligned
|
||||
* <span> <span> <span>
|
||||
* <description> <description>
|
||||
* <continued..>
|
||||
* ```
|
||||
*/
|
||||
@Composable
|
||||
private fun WidgetsFlowRow(
|
||||
widgetSizeGroup: WidgetSizeGroup,
|
||||
showAllWidgetDetails: Boolean,
|
||||
appIcons: Map<WidgetAppId, WidgetAppIcon>,
|
||||
previews: Map<WidgetId, WidgetPreview>,
|
||||
cellHorizontalPadding: Dp = WidgetGridDimensions.cellHorizontalPadding,
|
||||
rowVerticalSpacing: Dp = WidgetGridDimensions.rowVerticalSpacing,
|
||||
minItemWidth: Dp = WidgetGridDimensions.minItemWidth,
|
||||
) {
|
||||
val items = widgetSizeGroup.widgets
|
||||
|
||||
WidgetsFlowRowLayout(
|
||||
widgetPreviews = { Previews(items, previews) },
|
||||
widgetDetails = { Details(showAllWidgetDetails, items, appIcons) },
|
||||
previewContainerWidthPx = widgetSizeGroup.previewContainerWidthPx,
|
||||
cellHorizontalPadding = cellHorizontalPadding,
|
||||
rowVerticalSpacing = rowVerticalSpacing,
|
||||
minItemWidth = minItemWidth,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Previews(widgets: List<PickableWidget>, previews: Map<WidgetId, WidgetPreview>) {
|
||||
widgets.forEachIndexed { index, widgetItem ->
|
||||
val id = widgetItem.id
|
||||
|
||||
val widgetPreview: WidgetPreview =
|
||||
remember(id, previews) {
|
||||
previews.getOrDefault(id, WidgetPreview.PlaceholderWidgetPreview)
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
modifier =
|
||||
Modifier.fillMaxSize().clearAndSetSemantics { traversalIndex = index.toFloat() },
|
||||
) {
|
||||
WidgetPreview(sizeInfo = widgetItem.sizeInfo, preview = widgetPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Details(
|
||||
showAllWidgetDetails: Boolean,
|
||||
widgets: List<PickableWidget>,
|
||||
appIcons: Map<WidgetAppId, WidgetAppIcon>,
|
||||
) {
|
||||
widgets.forEachIndexed { index, widgetItem ->
|
||||
val appId = widgetItem.appId
|
||||
val icon = remember(appId, appIcons) { appIcons.getOrDefault(appId, null) }
|
||||
val appIcon: (@Composable () -> Unit)? =
|
||||
icon?.let { { WidgetAppIcon(widgetAppIcon = it, size = AppIconSize.SMALL) } }
|
||||
|
||||
WidgetDetails(
|
||||
widget = widgetItem,
|
||||
showAllDetails = showAllWidgetDetails,
|
||||
appIcon = appIcon,
|
||||
modifier =
|
||||
Modifier.semantics(mergeDescendants = true) { traversalIndex = index.toFloat() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetsFlowRowLayout(
|
||||
widgetPreviews: @Composable () -> Unit,
|
||||
widgetDetails: @Composable () -> Unit,
|
||||
previewContainerWidthPx: Int,
|
||||
cellHorizontalPadding: Dp,
|
||||
rowVerticalSpacing: Dp,
|
||||
minItemWidth: Dp,
|
||||
) {
|
||||
Layout(
|
||||
modifier = Modifier.semantics { isTraversalGroup = true },
|
||||
contents = listOf(widgetPreviews, widgetDetails),
|
||||
) { (widgetPreviewMeasurables, widgetDetailsMeasurables), constraints ->
|
||||
check(widgetPreviewMeasurables.size == widgetDetailsMeasurables.size)
|
||||
val parentWidth = constraints.maxWidth
|
||||
val rowVerticalSpacingPx = rowVerticalSpacing.roundToPx()
|
||||
val cellHorizontalPaddingPx = cellHorizontalPadding.roundToPx()
|
||||
val minItemWidthPx = minItemWidth.roundToPx()
|
||||
|
||||
val (possibleItemsPerRow, availableWidthPerItem) =
|
||||
calculateItemsPerRowAndMaxWidthPerItem(
|
||||
cellHorizontalPaddingPx = cellHorizontalPaddingPx,
|
||||
previewContainerWidthPx = previewContainerWidthPx,
|
||||
minItemWidthPx = minItemWidthPx,
|
||||
parentWidth = parentWidth,
|
||||
)
|
||||
|
||||
// Measure and group into rows
|
||||
val previewPlaceablesRows =
|
||||
widgetPreviewMeasurables.measureAndSplitIntoRows(
|
||||
itemsPerRow = possibleItemsPerRow,
|
||||
constraints =
|
||||
constraints.copy(
|
||||
maxWidth = availableWidthPerItem,
|
||||
maxHeight = Constraints.Infinity,
|
||||
),
|
||||
)
|
||||
val detailsPlaceableRows =
|
||||
widgetDetailsMeasurables.measureAndSplitIntoRows(
|
||||
itemsPerRow = possibleItemsPerRow,
|
||||
constraints =
|
||||
constraints.copy(
|
||||
maxWidth = availableWidthPerItem,
|
||||
maxHeight = Constraints.Infinity,
|
||||
),
|
||||
)
|
||||
check(previewPlaceablesRows.size == detailsPlaceableRows.size)
|
||||
|
||||
// Now we need:
|
||||
// 1) totalGridHeight to pass to layout constraints and
|
||||
// 2) height of the tallest preview in each row, and
|
||||
// 3) height of tallest details section in a row.
|
||||
val (totalGridHeight, measuredRowDimensions) =
|
||||
collectMeasuredDimensions(
|
||||
numberOfRows = previewPlaceablesRows.size,
|
||||
previewPlaceableRows = previewPlaceablesRows,
|
||||
detailsPlaceableRows = detailsPlaceableRows,
|
||||
rowVerticalSpacingPx = rowVerticalSpacingPx,
|
||||
)
|
||||
|
||||
// Place
|
||||
layout(constraints.maxWidth, totalGridHeight) {
|
||||
placeRows(
|
||||
previewPlaceableRows = previewPlaceablesRows,
|
||||
detailsPlaceableRows = detailsPlaceableRows,
|
||||
measuredRowDimensions = measuredRowDimensions,
|
||||
parentWidth = parentWidth,
|
||||
rowVerticalSpacingPx = rowVerticalSpacingPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectMeasuredDimensions(
|
||||
numberOfRows: Int,
|
||||
previewPlaceableRows: List<List<Placeable>>,
|
||||
detailsPlaceableRows: List<List<Placeable>>,
|
||||
rowVerticalSpacingPx: Int,
|
||||
): Pair<Int, MutableList<MeasuredRowDimensions>> {
|
||||
var totalGridHeight = 0
|
||||
val measuredRowDimensions = mutableListOf<MeasuredRowDimensions>()
|
||||
repeat(numberOfRows) { index ->
|
||||
val previewsRow = previewPlaceableRows[index]
|
||||
val detailsRow = detailsPlaceableRows[index]
|
||||
|
||||
val maxPreviewHeight = previewsRow.fastMaxOfOrDefault(0) { it.height }
|
||||
val maxDetailsHeight = detailsRow.fastMaxOfOrDefault(0) { it.height }
|
||||
val totalRowWidth = detailsRow.fastSumBy { it.width }
|
||||
|
||||
measuredRowDimensions.add(
|
||||
MeasuredRowDimensions(
|
||||
tallestPreviewHeight = maxPreviewHeight,
|
||||
tallestDetailsHeight = maxDetailsHeight,
|
||||
totalWidth = totalRowWidth,
|
||||
)
|
||||
)
|
||||
|
||||
totalGridHeight += maxPreviewHeight + maxDetailsHeight + rowVerticalSpacingPx
|
||||
}
|
||||
return Pair(totalGridHeight, measuredRowDimensions)
|
||||
}
|
||||
|
||||
private fun Placeable.PlacementScope.placeRows(
|
||||
previewPlaceableRows: List<List<Placeable>>,
|
||||
detailsPlaceableRows: List<List<Placeable>>,
|
||||
measuredRowDimensions: List<MeasuredRowDimensions>,
|
||||
parentWidth: Int,
|
||||
rowVerticalSpacingPx: Int,
|
||||
) {
|
||||
check(previewPlaceableRows.size == detailsPlaceableRows.size)
|
||||
val rowSize = previewPlaceableRows.size
|
||||
|
||||
var yPosition = 0
|
||||
repeat(rowSize) { index ->
|
||||
val previewsRow = previewPlaceableRows[index]
|
||||
val detailsRow = detailsPlaceableRows[index]
|
||||
val measuredRow = measuredRowDimensions[index]
|
||||
// Divide padding between items to center everything.
|
||||
val padding = ((parentWidth - measuredRow.totalWidth) / previewsRow.size) / 2
|
||||
|
||||
var xPosition = 0
|
||||
repeat(previewsRow.size) { rowItemIndex ->
|
||||
val detailItem = detailsRow[rowItemIndex]
|
||||
val previewItem = previewsRow[rowItemIndex]
|
||||
val itemWidth = max(detailItem.width, previewItem.width)
|
||||
|
||||
xPosition += padding
|
||||
|
||||
// Offset the preview by the difference in its height when compared to its
|
||||
// tallest sibling, so that it will appear bottom aligned.
|
||||
val previewTopOffset =
|
||||
measuredRow.tallestPreviewHeight - previewsRow[rowItemIndex].height
|
||||
previewsRow[rowItemIndex].placeRelative(xPosition, yPosition + previewTopOffset)
|
||||
// place details after size of the tallest preview
|
||||
detailsRow[rowItemIndex].placeRelative(
|
||||
xPosition,
|
||||
(yPosition + (measuredRow.tallestPreviewHeight)),
|
||||
)
|
||||
xPosition += itemWidth + padding // right padding
|
||||
}
|
||||
|
||||
// Move to next row
|
||||
yPosition +=
|
||||
measuredRow.tallestPreviewHeight +
|
||||
measuredRow.tallestDetailsHeight +
|
||||
rowVerticalSpacingPx
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateItemsPerRowAndMaxWidthPerItem(
|
||||
cellHorizontalPaddingPx: Int,
|
||||
previewContainerWidthPx: Int,
|
||||
minItemWidthPx: Int,
|
||||
parentWidth: Int,
|
||||
): Pair<Int, Int> {
|
||||
val totalItemHorizontalPadding = 2 * cellHorizontalPaddingPx
|
||||
|
||||
// Let's assume at minimum an item takes up preview container width
|
||||
val minWidthItemMightNeed = max(previewContainerWidthPx, minItemWidthPx)
|
||||
|
||||
// And with its horizontal padding added, we can then calculate how many items fit in a row
|
||||
// and then cap it to a maximum limit.
|
||||
val possibleItemsPerRow =
|
||||
(parentWidth / (minWidthItemMightNeed + totalItemHorizontalPadding)).coerceIn(
|
||||
minimumValue = 1,
|
||||
maximumValue = MAX_ITEMS_PER_ROW,
|
||||
)
|
||||
|
||||
// Using the capped number, we find out how much space will then be available for an item.
|
||||
val availableWidthPerItem =
|
||||
(parentWidth - (totalItemHorizontalPadding * possibleItemsPerRow)) / possibleItemsPerRow
|
||||
|
||||
return Pair(possibleItemsPerRow, availableWidthPerItem)
|
||||
}
|
||||
|
||||
private data class MeasuredRowDimensions(
|
||||
val tallestPreviewHeight: Int,
|
||||
val tallestDetailsHeight: Int,
|
||||
val totalWidth: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Measures the items and in same pass attempts to group the placeables into multiple rows based on
|
||||
* the available width.
|
||||
*/
|
||||
private fun List<Measurable>.measureAndSplitIntoRows(
|
||||
constraints: Constraints,
|
||||
itemsPerRow: Int,
|
||||
): List<List<Placeable>> {
|
||||
return fastFold(mutableListOf<MutableList<Placeable>>()) { rows, measurable ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
|
||||
if (rows.isEmpty() || rows.last().size == itemsPerRow) {
|
||||
rows.add(mutableListOf(placeable))
|
||||
} else {
|
||||
rows.last().add(placeable)
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
private object WidgetGridDimensions {
|
||||
val cellHorizontalPadding: Dp = 4.dp
|
||||
val rowVerticalSpacing: Dp = 12.dp
|
||||
val gridVerticalPadding: Dp = 16.dp
|
||||
|
||||
// We display at max 3 items side by side - which usually is case in case of shortcuts.
|
||||
const val MAX_ITEMS_PER_ROW = 3
|
||||
val minItemWidth = 100.dp
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* 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 androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldColors
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.android.launcher3.widgetpicker.R
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* A search bar shown in widget picker for searching widgets.
|
||||
*
|
||||
* By default shows a leading search icon, followed by the placeholder text. When user focuses on
|
||||
* the search bar, keyboard (if supported) is automatically opened, the search icon turns in to a
|
||||
* back button and user can either use this back button or the predictive back to go back.
|
||||
*
|
||||
* @param text currently entered text
|
||||
* @param isSearching whether user is actively searching and search results are shown
|
||||
* @param onSearch callback invoked when users types in the search bar; as an effect, [isSearching]
|
||||
* should be set to true
|
||||
* @param onToggleSearchMode callback invoked with `true` when user focuses on search bar to start a
|
||||
* search; false when user either presses back button within search bar or uses predictive back to
|
||||
* exit search.
|
||||
* @param modifier modifier for the top level search bar
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetsSearchBar(
|
||||
text: String,
|
||||
isSearching: Boolean,
|
||||
onSearch: (String) -> Unit,
|
||||
onToggleSearchMode: (Boolean) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val exitSearchMode = {
|
||||
onSearch("")
|
||||
focusManager.clearFocus()
|
||||
onToggleSearchMode(false)
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
modifier =
|
||||
modifier
|
||||
.heightIn(min = WidgetsSearchBarDimens.minHeight)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
if (focusState.isFocused) {
|
||||
onToggleSearchMode(true)
|
||||
}
|
||||
},
|
||||
value = text,
|
||||
onValueChange = { onSearch(it) },
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { onSearch(text) }),
|
||||
interactionSource = interactionSource,
|
||||
textStyle = WidgetsSearchBarDefaults.textStyle,
|
||||
decorationBox =
|
||||
@Composable { innerTextField ->
|
||||
WidgetsSearchBarContent(
|
||||
text = text,
|
||||
innerTextField = innerTextField,
|
||||
interactionSource = interactionSource,
|
||||
isSearching = isSearching,
|
||||
exitSearchMode = exitSearchMode,
|
||||
onClear = { onSearch("") },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(isSearching) {
|
||||
if (isSearching) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
PredictiveBackHandler(isSearching) { progress: Flow<BackEventCompat> ->
|
||||
try {
|
||||
progress.collect {}
|
||||
exitSearchMode()
|
||||
} catch (_: CancellationException) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetsSearchBarContent(
|
||||
text: String,
|
||||
innerTextField: @Composable () -> Unit,
|
||||
interactionSource: MutableInteractionSource,
|
||||
isSearching: Boolean,
|
||||
exitSearchMode: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = text,
|
||||
enabled = true,
|
||||
singleLine = true,
|
||||
innerTextField = innerTextField,
|
||||
interactionSource = interactionSource,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
contentPadding = WidgetsSearchBarDimens.paddingValues,
|
||||
container = {
|
||||
TextFieldDefaults.Container(
|
||||
enabled = true,
|
||||
isError = false,
|
||||
interactionSource = interactionSource,
|
||||
shape = CircleShape,
|
||||
colors = WidgetsSearchBarDefaults.containerColors,
|
||||
focusedIndicatorLineThickness = 0.dp,
|
||||
unfocusedIndicatorLineThickness = 0.dp,
|
||||
)
|
||||
},
|
||||
placeholder = { PlaceholderText() },
|
||||
leadingIcon = { LeadingButton(isSearching = isSearching, onBack = exitSearchMode) },
|
||||
trailingIcon = {
|
||||
if (text.isNotEmpty()) {
|
||||
ClearButton(onClick = onClear)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeadingButton(isSearching: Boolean, onBack: () -> Unit) {
|
||||
AnimatedContent(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.minimumInteractiveComponentSize(),
|
||||
targetState = isSearching,
|
||||
transitionSpec = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 300)) togetherWith
|
||||
fadeOut(animationSpec = tween(durationMillis = 300))
|
||||
},
|
||||
) { showBackButton ->
|
||||
if (showBackButton) {
|
||||
BackButton(onClick = onBack)
|
||||
} else {
|
||||
SearchIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchIcon() {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = null, // decorative
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackButton(onClick: () -> Unit) {
|
||||
IconButton(colors = WidgetsSearchBarDefaults.iconButtonColors, onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.widget_search_bar_clear_button_label),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderText() {
|
||||
Text(
|
||||
text = stringResource(R.string.widgets_search_bar_hint),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = WidgetsSearchBarDefaults.placeholderTextColor,
|
||||
style = WidgetsSearchBarDefaults.placeholderTextStyle,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClearButton(onClick: () -> Unit) {
|
||||
IconButton(colors = WidgetsSearchBarDefaults.iconButtonColors, onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.widget_search_bar_clear_button_label),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object WidgetsSearchBarDimens {
|
||||
val paddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
|
||||
val minHeight = 52.dp
|
||||
}
|
||||
|
||||
private object WidgetsSearchBarDefaults {
|
||||
val containerColors: TextFieldColors
|
||||
@Composable
|
||||
get() =
|
||||
TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
|
||||
)
|
||||
|
||||
val placeholderTextColor: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
val iconButtonColors
|
||||
@Composable
|
||||
get() = IconButtonDefaults.iconButtonColors().copy(containerColor = Color.Transparent)
|
||||
|
||||
val textStyle: TextStyle
|
||||
@Composable
|
||||
get() =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
val placeholderTextStyle: TextStyle
|
||||
@Composable
|
||||
get() =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
|
||||
|
||||
/**
|
||||
* Information about the widget app transformed for displaying as a expandable section in UI.
|
||||
*
|
||||
* @param id unique id for the app section
|
||||
* @param title title for the widget
|
||||
* @param widgetSizeGroups groups of similar sized widgets that can be displayed together
|
||||
* @param widgetsCount total number of widgets in the app
|
||||
*/
|
||||
@Stable
|
||||
@Immutable
|
||||
data class DisplayableWidgetApp(
|
||||
val id: WidgetAppId,
|
||||
val title: CharSequence?,
|
||||
val widgetSizeGroups: List<WidgetSizeGroup>,
|
||||
val widgetsCount: Int,
|
||||
)
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
|
||||
|
||||
/**
|
||||
* A group of widgets that are bucketed into a same sized container that can to be arranged in same
|
||||
* or consecutive rows within the appwidget grid.
|
||||
*
|
||||
* For example, if 2x2 and 3x2 widgets are all bucketed to 2x2 cell size's container, all those
|
||||
* widgets will be in this group with the [previewContainerHeightPx] and [previewContainerWidthPx]
|
||||
* being the pixel sizes of 2x2 span size. Grouping these same height widgets, enables us to
|
||||
* visually show the widgets side by side and enable visual coherence in the grid.
|
||||
*/
|
||||
@Stable
|
||||
@Immutable
|
||||
data class WidgetSizeGroup(
|
||||
val previewContainerHeightPx: Int,
|
||||
val previewContainerWidthPx: Int,
|
||||
val widgets: List<PickableWidget>,
|
||||
)
|
||||
@@ -36,17 +36,21 @@ android_test {
|
||||
srcs: [
|
||||
"multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/goldenpathmanager/WidgetPickerGoldenPathManager.kt",
|
||||
"multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/ui/components/FloatingToolbarScreenshotTest.kt",
|
||||
"multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGridScreenshotTest.kt",
|
||||
"multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGridTestSamples.kt",
|
||||
],
|
||||
sdk_version: "current",
|
||||
static_libs: [
|
||||
"widget_picker_ui_components",
|
||||
"collector-device-lib-platform",
|
||||
"widget_picker_window_size_class",
|
||||
"testables",
|
||||
"androidx.compose.runtime_runtime",
|
||||
"junit",
|
||||
"androidx.test.ext.junit",
|
||||
"androidx.test.rules",
|
||||
"androidx.test.runner",
|
||||
"platform-test-rules",
|
||||
"platform-screenshot-diff-core",
|
||||
"platform-parametric-runner-lib",
|
||||
"ScreenshotComposeUtilsLib",
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
+4
@@ -16,6 +16,7 @@
|
||||
|
||||
package com.android.launcher3.widgetpicker.ui.components
|
||||
|
||||
import android.platform.test.rule.DisableAnimationsRule
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -51,6 +52,8 @@ import platform.test.screenshot.utils.compose.ComposeScreenshotTestRule
|
||||
|
||||
@RunWith(ParameterizedAndroidJunit4::class)
|
||||
class FloatingToolbarScreenshotTest(emulationSpec: DeviceEmulationSpec) {
|
||||
@get:Rule(order = 0) val disableAnimationsRule = DisableAnimationsRule()
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val screenshotRule =
|
||||
ComposeScreenshotTestRule(
|
||||
@@ -179,6 +182,7 @@ private fun TestComposable(
|
||||
ScrollableFloatingToolbar(
|
||||
modifier = Modifier.wrapContentSize(align = Alignment.Center),
|
||||
selectedTabIndex = selectedIndex,
|
||||
shadowElevation = 0.dp,
|
||||
tabs =
|
||||
tabs.map {
|
||||
{
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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.DisableAnimationsRule
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.launcher3.widgetpicker.goldenpathmanager.WidgetPickerGoldenPathManager
|
||||
import com.android.launcher3.widgetpicker.shared.model.WidgetId
|
||||
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
|
||||
import com.android.launcher3.widgetpicker.ui.model.WidgetSizeGroup
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import platform.test.runner.parameterized.ParameterizedAndroidJunit4
|
||||
import platform.test.runner.parameterized.Parameters
|
||||
import platform.test.screenshot.DeviceEmulationSpec
|
||||
import platform.test.screenshot.Displays
|
||||
import platform.test.screenshot.getEmulatedDevicePathConfig
|
||||
import platform.test.screenshot.utils.compose.ComposeScreenshotTestRule
|
||||
|
||||
@RunWith(ParameterizedAndroidJunit4::class)
|
||||
class WidgetsGridScreenshotTest(emulationSpec: DeviceEmulationSpec) {
|
||||
@get:Rule(order = 0) val disableAnimationsRule = DisableAnimationsRule()
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val screenshotRule =
|
||||
ComposeScreenshotTestRule(
|
||||
emulationSpec,
|
||||
WidgetPickerGoldenPathManager(getEmulatedDevicePathConfig(emulationSpec)),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun widgetsGrid_multipleSizeGroups() {
|
||||
screenshotRule.screenshotTest("widgetsGrid_multipleSizeGroups") {
|
||||
MultipleSizeGroupsPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun widgetsGrid_oneByOneWidgets() {
|
||||
screenshotRule.screenshotTest("widgetsGrid_oneByOneWidgets") { OneByOneWidgetsPreview() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Parameters(name = "{0}")
|
||||
@JvmStatic
|
||||
fun getTestSpecs(): List<DeviceEmulationSpec> {
|
||||
return DeviceEmulationSpec.forDisplays(
|
||||
Displays.Phone,
|
||||
isDarkTheme = false,
|
||||
isLandscape = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 420)
|
||||
@Composable
|
||||
private fun MultipleSizeGroupsPreview() {
|
||||
val testWidth = 420.dp
|
||||
val testWidthPx = with(LocalDensity.current) { testWidth.roundToPx() }
|
||||
val sample = WidgetsGridTestSamples.varyingSizedWidgets(testWidthPx)
|
||||
|
||||
GridPreview(groups = sample.widgetSizeGroups, testWidth = testWidth, previews = sample.previews)
|
||||
}
|
||||
|
||||
@Preview(widthDp = 420)
|
||||
@Composable
|
||||
private fun OneByOneWidgetsPreview() {
|
||||
val testWidth = 420.dp
|
||||
val testWidthPx = with(LocalDensity.current) { testWidth.roundToPx() }
|
||||
val sample = WidgetsGridTestSamples.oneByOneWidgets(testWidthPx)
|
||||
|
||||
GridPreview(groups = sample.widgetSizeGroups, testWidth = testWidth, previews = sample.previews)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridPreview(
|
||||
groups: List<WidgetSizeGroup>,
|
||||
previews: Map<WidgetId, WidgetPreview>,
|
||||
testWidth: Dp,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier.width(testWidth)
|
||||
.wrapContentHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceBright),
|
||||
) {
|
||||
WidgetsGrid(
|
||||
widgetSizeGroups = groups,
|
||||
showAllWidgetDetails = true,
|
||||
previews = previews,
|
||||
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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.ComponentName
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.os.Process.myUserHandle
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
|
||||
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.ui.model.WidgetSizeGroup
|
||||
|
||||
/**
|
||||
* Different combinations of test widget groupings to verify behavior of arranging them in a grid.
|
||||
*/
|
||||
object WidgetsGridTestSamples {
|
||||
/** A test sample with 3 size groups: 2x2s, 4x2s and 1x1s */
|
||||
fun varyingSizedWidgets(screenWidth: Int): WidgetsSample {
|
||||
val (cellWidth, cellHeight) = testCellSize(screenWidth)
|
||||
|
||||
val fillsBoundsWidgetId = newWidgetId("FillsBounds")
|
||||
val threeByTwoWidgetId = newWidgetId("ThreeByTwo")
|
||||
val noFillBoundsWidgetId = newWidgetId("NoFillBounds")
|
||||
val tinyImageWidgetId = newWidgetId("TinyImage")
|
||||
val fourByTwoWidgetId = newWidgetId("FourByTwo")
|
||||
|
||||
return WidgetsSample(
|
||||
previews =
|
||||
mapOf(
|
||||
fillsBoundsWidgetId to
|
||||
createBitmapPreview(cellWidth * 2, cellHeight * 2, Color.RED),
|
||||
threeByTwoWidgetId to
|
||||
createBitmapPreview(cellWidth * 3, cellHeight * 2, Color.GREEN),
|
||||
noFillBoundsWidgetId to
|
||||
createBitmapPreview(cellWidth * 3, cellHeight * 3, Color.BLUE),
|
||||
tinyImageWidgetId to
|
||||
createBitmapPreview(cellWidth, cellHeight / 2, Color.YELLOW),
|
||||
fourByTwoWidgetId to
|
||||
createBitmapPreview(cellWidth * 3, cellHeight * 2, Color.LTGRAY),
|
||||
),
|
||||
widgetSizeGroups =
|
||||
listOf(
|
||||
// widgets with 2x2 container size
|
||||
WidgetSizeGroup(
|
||||
previewContainerWidthPx = cellWidth * 2,
|
||||
previewContainerHeightPx = cellHeight * 2,
|
||||
widgets =
|
||||
listOf(
|
||||
twoByTwo(cellWidth, cellHeight)
|
||||
.copy(id = fillsBoundsWidgetId, label = "Fills bounds"),
|
||||
threeByTwo(cellWidth, cellHeight)
|
||||
.copy(
|
||||
id = threeByTwoWidgetId,
|
||||
label = "Three by two",
|
||||
description = "Scaled to 2x2 container",
|
||||
),
|
||||
twoByTwo(cellWidth, cellHeight)
|
||||
.copy(
|
||||
id = noFillBoundsWidgetId,
|
||||
label = "Doesn't Fill bounds",
|
||||
description =
|
||||
"Actual image size is more like 3x3, it will be scaled.",
|
||||
),
|
||||
twoByTwo(cellWidth, cellHeight)
|
||||
.copy(
|
||||
id = tinyImageWidgetId,
|
||||
label = "Tiny image",
|
||||
description = "Should scale up",
|
||||
),
|
||||
),
|
||||
),
|
||||
// widgets with 4x2 size
|
||||
WidgetSizeGroup(
|
||||
previewContainerWidthPx = cellWidth * 4,
|
||||
previewContainerHeightPx = cellHeight * 2,
|
||||
widgets =
|
||||
listOf(
|
||||
fourByTwo(cellWidth, cellHeight)
|
||||
.copy(id = fourByTwoWidgetId, label = "FourByTwo 3x2 image")
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A group of 1x1 sized widgets. */
|
||||
fun oneByOneWidgets(screenWidth: Int): WidgetsSample {
|
||||
val (cellWidth, cellHeight) = testCellSize(screenWidth)
|
||||
|
||||
val largeWidthWidgetId = newWidgetId("LargerWidth")
|
||||
val largeHeightWidgetId = newWidgetId("LargerHeight")
|
||||
val tinySizeWidgetId = newWidgetId("TinySize")
|
||||
val correctSizeWidthId = newWidgetId("CorrectSize")
|
||||
|
||||
return WidgetsSample(
|
||||
widgetSizeGroups =
|
||||
listOf(
|
||||
WidgetSizeGroup(
|
||||
previewContainerWidthPx = cellWidth,
|
||||
previewContainerHeightPx = cellHeight,
|
||||
widgets =
|
||||
listOf(
|
||||
oneByOne(cellWidth, cellHeight)
|
||||
.copy(id = largeWidthWidgetId, label = "Larger width"),
|
||||
oneByOne(cellWidth, cellHeight)
|
||||
.copy(
|
||||
id = largeHeightWidgetId,
|
||||
label = "Larger height",
|
||||
description = "Has a description",
|
||||
),
|
||||
oneByOne(cellWidth, cellHeight)
|
||||
.copy(
|
||||
id = newWidgetId("TinySize"),
|
||||
label = "Tiny size",
|
||||
description = "Slightly longer description",
|
||||
),
|
||||
oneByOne(cellWidth, cellHeight)
|
||||
.copy(id = newWidgetId("CorrectSize"), label = "Correct size"),
|
||||
),
|
||||
)
|
||||
),
|
||||
previews =
|
||||
mapOf(
|
||||
largeWidthWidgetId to
|
||||
createBitmapPreview(cellWidth * 2, cellHeight, Color.YELLOW),
|
||||
largeHeightWidgetId to
|
||||
createBitmapPreview(cellWidth, cellHeight * 3, Color.BLUE),
|
||||
tinySizeWidgetId to
|
||||
createBitmapPreview(cellWidth / 2, cellHeight / 3, Color.RED),
|
||||
correctSizeWidthId to createBitmapPreview(cellWidth, cellHeight, Color.GREEN),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun oneByOne(cellWidth: Int, cellHeight: Int) =
|
||||
PickableWidget(
|
||||
id = newWidgetId("OneByOne"),
|
||||
appId = TEST_WIDGET_APP_ID,
|
||||
label = "One by One",
|
||||
description = null,
|
||||
sizeInfo =
|
||||
WidgetSizeInfo(
|
||||
spanX = 1,
|
||||
spanY = 1,
|
||||
widthPx = cellWidth,
|
||||
heightPx = cellHeight,
|
||||
containerWidthPx = cellWidth,
|
||||
containerHeightPx = cellHeight,
|
||||
),
|
||||
appWidgetProviderInfo = AppWidgetProviderInfo(),
|
||||
)
|
||||
|
||||
private fun twoByTwo(cellWidth: Int, cellHeight: Int) =
|
||||
PickableWidget(
|
||||
id = newWidgetId("TwoByTwo"),
|
||||
appId = TEST_WIDGET_APP_ID,
|
||||
label = "One by One",
|
||||
description = null,
|
||||
appWidgetProviderInfo = AppWidgetProviderInfo(),
|
||||
sizeInfo =
|
||||
WidgetSizeInfo(
|
||||
spanX = 2,
|
||||
spanY = 2,
|
||||
widthPx = cellWidth * 2,
|
||||
heightPx = cellHeight * 2,
|
||||
// container same as size
|
||||
containerWidthPx = cellWidth * 2,
|
||||
containerHeightPx = cellHeight * 2,
|
||||
),
|
||||
)
|
||||
|
||||
private fun threeByTwo(cellWidth: Int, cellHeight: Int) =
|
||||
PickableWidget(
|
||||
id = newWidgetId("ThreeByTwo"),
|
||||
appId = TEST_WIDGET_APP_ID,
|
||||
label = "One by One",
|
||||
description = null,
|
||||
appWidgetProviderInfo = AppWidgetProviderInfo(),
|
||||
sizeInfo =
|
||||
WidgetSizeInfo(
|
||||
spanX = 3,
|
||||
spanY = 2,
|
||||
widthPx = cellWidth * 3,
|
||||
heightPx = cellHeight * 2,
|
||||
// 3x2s are bucketed to 2x2 container
|
||||
containerWidthPx = cellWidth * 2,
|
||||
containerHeightPx = cellHeight * 2,
|
||||
),
|
||||
)
|
||||
|
||||
private fun fourByTwo(cellWidth: Int, cellHeight: Int) =
|
||||
PickableWidget(
|
||||
id = newWidgetId("FourByTwo"),
|
||||
appId = TEST_WIDGET_APP_ID,
|
||||
label = "Four by two",
|
||||
description = null,
|
||||
appWidgetProviderInfo = AppWidgetProviderInfo(),
|
||||
sizeInfo =
|
||||
WidgetSizeInfo(
|
||||
spanX = 4,
|
||||
spanY = 2,
|
||||
widthPx = cellWidth * 4,
|
||||
heightPx = cellHeight * 2,
|
||||
// container same as size
|
||||
containerWidthPx = cellWidth * 4,
|
||||
containerHeightPx = cellHeight * 2,
|
||||
),
|
||||
)
|
||||
|
||||
private fun newWidgetId(suffix: String) =
|
||||
WidgetId(
|
||||
ComponentName.createRelative(PACKAGE_NAME, "WidgetReceiver$suffix"),
|
||||
myUserHandle(),
|
||||
)
|
||||
|
||||
private fun createBitmapPreview(
|
||||
width: Int,
|
||||
height: Int,
|
||||
color: Int,
|
||||
): WidgetPreview.BitmapWidgetPreview {
|
||||
val bitmap: Bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
Canvas(bitmap).drawColor(color)
|
||||
|
||||
return WidgetPreview.BitmapWidgetPreview(bitmap = bitmap)
|
||||
}
|
||||
|
||||
private fun testCellSize(screenWidthPx: Int): IntSize {
|
||||
val gridCols = 4
|
||||
val totalHorizontalGridCellPadding = 40 * 2
|
||||
val cellWidth = ((screenWidthPx / gridCols) - totalHorizontalGridCellPadding)
|
||||
val cellHeight = cellWidth + 20 // assume cell height slightly larger than width
|
||||
|
||||
return IntSize(cellWidth, cellHeight)
|
||||
}
|
||||
|
||||
private const val PACKAGE_NAME = "com.android.widgetpicker.tests"
|
||||
private val TEST_WIDGET_APP_ID = WidgetAppId(PACKAGE_NAME, myUserHandle(), category = null)
|
||||
}
|
||||
|
||||
data class WidgetsSample(
|
||||
val widgetSizeGroups: List<WidgetSizeGroup>,
|
||||
val previews: Map<WidgetId, WidgetPreview>,
|
||||
)
|
||||
Reference in New Issue
Block a user