1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.launcher3.widget.picker;
18 
19 import static com.android.launcher3.widget.util.WidgetsTableUtils.groupWidgetItemsUsingRowPxWithoutReordering;
20 
21 import android.content.ComponentName;
22 import android.content.Context;
23 import android.os.Bundle;
24 import android.util.AttributeSet;
25 import android.view.LayoutInflater;
26 import android.view.View;
27 import android.view.ViewGroup;
28 import android.widget.TextView;
29 
30 import androidx.annotation.Nullable;
31 import androidx.annotation.Px;
32 
33 import com.android.launcher3.DeviceProfile;
34 import com.android.launcher3.PagedView;
35 import com.android.launcher3.R;
36 import com.android.launcher3.Utilities;
37 import com.android.launcher3.model.WidgetItem;
38 import com.android.launcher3.pageindicators.PageIndicatorDots;
39 
40 import java.util.ArrayList;
41 import java.util.Collections;
42 import java.util.HashSet;
43 import java.util.List;
44 import java.util.Map;
45 import java.util.Set;
46 import java.util.TreeMap;
47 import java.util.function.Consumer;
48 import java.util.stream.Collectors;
49 
50 /**
51  * A {@link PagedView} that displays widget recommendations in categories with dots as paged
52  * indicators.
53  */
54 public final class WidgetRecommendationsView extends PagedView<PageIndicatorDots> {
55     private @Px float mAvailableHeight = Float.MAX_VALUE;
56     private @Px float mAvailableWidth = 0;
57     private static final String INITIALLY_DISPLAYED_WIDGETS_STATE_KEY =
58             "widgetRecommendationsView:mDisplayedWidgets";
59     private static final int MAX_CATEGORIES = 3;
60     private TextView mRecommendationPageTitle;
61     private final List<String> mCategoryTitles = new ArrayList<>();
62 
63     /** Callbacks to run when page changes */
64     private final List<Consumer<Integer>> mPageSwitchListeners = new ArrayList<>();
65 
66     @Nullable
67     private OnLongClickListener mWidgetCellOnLongClickListener;
68     @Nullable
69     private OnClickListener mWidgetCellOnClickListener;
70     private Set<ComponentName> mDisplayedWidgets = Collections.emptySet();
71 
WidgetRecommendationsView(Context context)72     public WidgetRecommendationsView(Context context) {
73         this(context, /* attrs= */ null);
74     }
75 
WidgetRecommendationsView(Context context, AttributeSet attrs)76     public WidgetRecommendationsView(Context context, AttributeSet attrs) {
77         this(context, attrs, /* defStyleAttr= */ 0);
78     }
79 
WidgetRecommendationsView(Context context, AttributeSet attrs, int defStyle)80     public WidgetRecommendationsView(Context context, AttributeSet attrs, int defStyle) {
81         super(context, attrs, defStyle);
82     }
83 
84     @Override
initParentViews(View parent)85     public void initParentViews(View parent) {
86         super.initParentViews(parent);
87         mRecommendationPageTitle = parent.findViewById(R.id.recommendations_page_title);
88     }
89 
90     /**
91      * Saves the necessary state in the provided bundle. To be called in case of orientation /
92      * other config changes.
93      */
saveState(Bundle bundle)94     public void saveState(Bundle bundle) {
95         // Save the widgets that were displayed, so that, on rotation / fold / unfold, we can
96         // maintain the "initial" set of widgets that user first saw (if they fit).
97         bundle.putParcelableArrayList(INITIALLY_DISPLAYED_WIDGETS_STATE_KEY,
98                 new ArrayList<>(mDisplayedWidgets));
99     }
100 
101     /**
102      * Restores the state that was saved by the saveState method during orientation / other config
103      * changes.
104      */
restoreState(Bundle bundle)105     public void restoreState(Bundle bundle) {
106         ArrayList<ComponentName> componentList;
107         if (Utilities.ATLEAST_T) {
108             componentList = bundle.getParcelableArrayList(
109                     INITIALLY_DISPLAYED_WIDGETS_STATE_KEY, ComponentName.class);
110         } else {
111             componentList = bundle.getParcelableArrayList(
112                     INITIALLY_DISPLAYED_WIDGETS_STATE_KEY);
113         }
114 
115         // Restore the "initial" set of widgets that were displayed, so that, on rotation / fold /
116         // unfold, we can maintain the set of widgets that user first saw (if they fit).
117         if (componentList != null) {
118             mDisplayedWidgets = new HashSet<>(componentList);
119         }
120     }
121 
122     /** Sets a {@link android.view.View.OnLongClickListener} for all widget cells in this table. */
setWidgetCellLongClickListener(OnLongClickListener onLongClickListener)123     public void setWidgetCellLongClickListener(OnLongClickListener onLongClickListener) {
124         mWidgetCellOnLongClickListener = onLongClickListener;
125     }
126 
127     /** Sets a {@link android.view.View.OnClickListener} for all widget cells in this table. */
setWidgetCellOnClickListener(OnClickListener widgetCellOnClickListener)128     public void setWidgetCellOnClickListener(OnClickListener widgetCellOnClickListener) {
129         mWidgetCellOnClickListener = widgetCellOnClickListener;
130     }
131 
132     /**
133      * Add a callback to run when the current displayed page changes.
134      */
addPageSwitchListener(Consumer<Integer> pageChangeListener)135     public void addPageSwitchListener(Consumer<Integer> pageChangeListener) {
136         mPageSwitchListeners.add(pageChangeListener);
137     }
138 
139     /**
140      * Displays all the provided recommendations in a single table if they fit.
141      *
142      * @param recommendedWidgets list of widgets to be displayed in recommendation section.
143      * @param deviceProfile      the current {@link DeviceProfile}
144      * @param availableHeight    height in px that can be used to display the recommendations;
145      *                           recommendations that don't fit in this height won't be shown
146      * @param availableWidth     width in px that the recommendations should display in
147      * @param cellPadding        padding in px that should be applied to each widget in the
148      *                           recommendations
149      * @return number of recommendations that could fit in the available space.
150      */
setRecommendations( List<WidgetItem> recommendedWidgets, DeviceProfile deviceProfile, final @Px float availableHeight, final @Px int availableWidth, final @Px int cellPadding)151     public int setRecommendations(
152             List<WidgetItem> recommendedWidgets, DeviceProfile deviceProfile,
153             final @Px float availableHeight, final @Px int availableWidth,
154             final @Px int cellPadding) {
155         this.mAvailableHeight = availableHeight;
156         this.mAvailableWidth = availableWidth;
157         clear();
158 
159         Set<ComponentName> displayedWidgets = maybeDisplayInTable(recommendedWidgets,
160                 deviceProfile,
161                 availableWidth, cellPadding);
162 
163         if (mDisplayedWidgets.isEmpty()) {
164             // Save the widgets shown for the first time user opened the picker; so that, they can
165             // be maintained across orientation changes.
166             mDisplayedWidgets = displayedWidgets;
167         }
168 
169         updateTitleAndIndicator(/* requestedPage= */ 0);
170         return displayedWidgets.size();
171     }
172 
173     /**
174      * Displays the recommendations grouped by categories as pages.
175      * <p>In case of a single category, no title is displayed for it.</p>
176      *
177      * @param recommendations a map of widget items per recommendation category
178      * @param deviceProfile   the current {@link DeviceProfile}
179      * @param availableHeight height in px that can be used to display the recommendations;
180      *                        recommendations that don't fit in this height won't be shown
181      * @param availableWidth  width in px that the recommendations should display in
182      * @param cellPadding     padding in px that should be applied to each widget in the
183      *                        recommendations
184      * @param requestedPage   page number to display initially.
185      * @return number of recommendations that could fit in the available space.
186      */
setRecommendations( Map<WidgetRecommendationCategory, List<WidgetItem>> recommendations, DeviceProfile deviceProfile, final @Px float availableHeight, final @Px int availableWidth, final @Px int cellPadding, final int requestedPage)187     public int setRecommendations(
188             Map<WidgetRecommendationCategory, List<WidgetItem>> recommendations,
189             DeviceProfile deviceProfile, final @Px float availableHeight,
190             final @Px int availableWidth, final @Px int cellPadding, final int requestedPage) {
191         this.mAvailableHeight = availableHeight;
192         this.mAvailableWidth = availableWidth;
193         Context context = getContext();
194         // For purpose of recommendations section, we don't want paging dots to be halved in two
195         // pane display, so, we always provide isTwoPanels = "false".
196         mPageIndicator.setPauseScroll(/*pause=*/true, /*isTwoPanels=*/ false);
197         clear();
198 
199         int displayedCategories = 0;
200         Set<ComponentName> allDisplayedWidgets = new HashSet<>();
201 
202         // Render top MAX_CATEGORIES in separate tables. Each table becomes a page.
203         for (Map.Entry<WidgetRecommendationCategory, List<WidgetItem>> entry :
204                 new TreeMap<>(recommendations).entrySet()) {
205             // If none of the recommendations for the category could fit in the mAvailableHeight, we
206             // don't want to add that category; and we look for the next one.
207             Set<ComponentName> displayedWidgetsForCategory = maybeDisplayInTable(entry.getValue(),
208                     deviceProfile,
209                     availableWidth, cellPadding);
210             if (!displayedWidgetsForCategory.isEmpty()) {
211                 mCategoryTitles.add(
212                         context.getResources().getString(entry.getKey().categoryTitleRes));
213                 displayedCategories++;
214                 allDisplayedWidgets.addAll(displayedWidgetsForCategory);
215             }
216 
217             if (displayedCategories == MAX_CATEGORIES) {
218                 break;
219             }
220         }
221 
222         if (mDisplayedWidgets.isEmpty()) {
223             // Save the widgets shown for the first time user opened the picker; so that, they can
224             // be maintained across orientation changes.
225             mDisplayedWidgets = allDisplayedWidgets;
226         }
227 
228         updateTitleAndIndicator(requestedPage);
229         // For purpose of recommendations section, we don't want paging dots to be halved in two
230         // pane display, so, we always provide isTwoPanels = "false".
231         mPageIndicator.setPauseScroll(/*pause=*/false, /*isTwoPanels=*/false);
232         return allDisplayedWidgets.size();
233     }
234 
clear()235     private void clear() {
236         mCategoryTitles.clear();
237         removeAllViews();
238         setCurrentPage(0);
239         mPageIndicator.setActiveMarker(0);
240     }
241 
242     /** Displays the page title and paging indicator if there are multiple pages. */
updateTitleAndIndicator(int requestedPage)243     private void updateTitleAndIndicator(int requestedPage) {
244         boolean showPaginatedView = getPageCount() > 1;
245         int titleAndIndicatorVisibility = showPaginatedView ? View.VISIBLE : View.GONE;
246         mRecommendationPageTitle.setVisibility(titleAndIndicatorVisibility);
247         mPageIndicator.setVisibility(titleAndIndicatorVisibility);
248         if (showPaginatedView) {
249             if (requestedPage <= 0 || requestedPage >= getPageCount()) {
250                 requestedPage = 0;
251             }
252             setCurrentPage(requestedPage);
253             mPageIndicator.setActiveMarker(requestedPage);
254             mRecommendationPageTitle.setText(mCategoryTitles.get(requestedPage));
255         }
256     }
257 
258     @Override
notifyPageSwitchListener(int prevPage)259     protected void notifyPageSwitchListener(int prevPage) {
260         if (getPageCount() > 1) {
261             // Since the title is outside the paging scroll, we update the title on page switch.
262             int nextPage = getNextPage();
263             mRecommendationPageTitle.setText(mCategoryTitles.get(nextPage));
264             mPageSwitchListeners.forEach(listener -> listener.accept(nextPage));
265             super.notifyPageSwitchListener(prevPage);
266         }
267     }
268 
269     @Override
canScroll(float absVScroll, float absHScroll)270     protected boolean canScroll(float absVScroll, float absHScroll) {
271         // Allow only horizontal scroll.
272         return (absHScroll > absVScroll) && super.canScroll(absVScroll, absHScroll);
273     }
274 
275     @Override
onScrollChanged(int l, int t, int oldl, int oldt)276     protected void onScrollChanged(int l, int t, int oldl, int oldt) {
277         super.onScrollChanged(l, t, oldl, oldt);
278         mPageIndicator.setScroll(l, mMaxScroll);
279     }
280 
281     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)282     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
283         boolean hasMultiplePages = getChildCount() > 0;
284 
285         if (hasMultiplePages) {
286             int desiredHeight = 0;
287             int desiredWidth = Math.round(mAvailableWidth);
288 
289             for (int i = 0; i < getChildCount(); i++) {
290                 View child = getChildAt(i);
291                 // Measure children based on available height and width.
292                 measureChild(child,
293                         MeasureSpec.makeMeasureSpec(desiredWidth, MeasureSpec.EXACTLY),
294                         MeasureSpec.makeMeasureSpec(Math.round(mAvailableHeight),
295                                 MeasureSpec.AT_MOST));
296                 // Use height of tallest child as we have limited height.
297                 int childHeight = child.getMeasuredHeight();
298                 desiredHeight = Math.max(desiredHeight, childHeight);
299             }
300 
301             int finalHeight = resolveSizeAndState(desiredHeight, heightMeasureSpec, 0);
302             int finalWidth = resolveSizeAndState(desiredWidth, widthMeasureSpec, 0);
303 
304             setMeasuredDimension(finalWidth, finalHeight);
305         } else {
306             super.onMeasure(widthMeasureSpec, heightMeasureSpec);
307         }
308     }
309 
310     /**
311      * Groups the provided recommendations into rows and displays ones that fit in a table.
312      * <p>Returns the set of widgets that could fit.</p>
313      */
maybeDisplayInTable(List<WidgetItem> recommendedWidgets, DeviceProfile deviceProfile, final @Px int availableWidth, final @Px int cellPadding)314     private Set<ComponentName> maybeDisplayInTable(List<WidgetItem> recommendedWidgets,
315             DeviceProfile deviceProfile,
316             final @Px int availableWidth, final @Px int cellPadding) {
317         List<WidgetItem> filteredRecommendedWidgets = recommendedWidgets;
318         // Show only those widgets that were displayed when user first opened the picker.
319         if (!mDisplayedWidgets.isEmpty()) {
320             filteredRecommendedWidgets = recommendedWidgets.stream().filter(
321                     w -> mDisplayedWidgets.contains(w.componentName)).toList();
322         }
323         Context context = getContext();
324         LayoutInflater inflater = LayoutInflater.from(context);
325 
326         // Since we are limited by space, we don't sort recommendations - to show most relevant
327         // (if possible).
328         List<ArrayList<WidgetItem>> rows = groupWidgetItemsUsingRowPxWithoutReordering(
329                 filteredRecommendedWidgets,
330                 context,
331                 deviceProfile,
332                 availableWidth,
333                 cellPadding);
334 
335         WidgetsRecommendationTableLayout recommendationsTable =
336                 (WidgetsRecommendationTableLayout) inflater.inflate(
337                         R.layout.widget_recommendations_table,
338                         /* root=*/ this,
339                         /* attachToRoot=*/ false);
340         recommendationsTable.setWidgetCellOnClickListener(mWidgetCellOnClickListener);
341         recommendationsTable.setWidgetCellLongClickListener(mWidgetCellOnLongClickListener);
342 
343         List<ArrayList<WidgetItem>> displayedItems = recommendationsTable.setRecommendedWidgets(
344                 rows,
345                 deviceProfile, mAvailableHeight);
346 
347         if (!displayedItems.isEmpty()) {
348             addView(recommendationsTable);
349         }
350 
351         return displayedItems.stream().flatMap(
352                         items -> items.stream().map(w -> w.componentName))
353                 .collect(Collectors.toSet());
354     }
355 
356     /** Returns location of a widget cell for displaying the "touch and hold" education tip. */
getViewForEducationTip()357     public View getViewForEducationTip() {
358         if (getChildCount() > 0) {
359             // first page (a table layout) -> first item (a widget cell).
360             return ((ViewGroup) getChildAt(0)).getChildAt(0);
361         }
362         return null;
363     }
364 }
365