1 /*
2  * Copyright (C) 2023 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 package com.android.launcher3.widget.picker;
17 
18 import static com.android.launcher3.Flags.enableCategorizedWidgetSuggestions;
19 import static com.android.launcher3.Flags.enableUnfoldedTwoPanePicker;
20 import static com.android.launcher3.UtilitiesKt.CLIP_CHILDREN_FALSE_MODIFIER;
21 import static com.android.launcher3.UtilitiesKt.CLIP_TO_PADDING_FALSE_MODIFIER;
22 import static com.android.launcher3.UtilitiesKt.modifyAttributesOnViewTree;
23 import static com.android.launcher3.UtilitiesKt.restoreAttributesOnViewTree;
24 
25 import android.content.Context;
26 import android.graphics.Rect;
27 import android.os.Process;
28 import android.util.AttributeSet;
29 import android.view.LayoutInflater;
30 import android.view.MotionEvent;
31 import android.view.View;
32 import android.view.ViewGroup;
33 import android.view.ViewParent;
34 import android.widget.FrameLayout;
35 import android.widget.LinearLayout;
36 import android.widget.ScrollView;
37 
38 import androidx.annotation.NonNull;
39 import androidx.annotation.Nullable;
40 import androidx.annotation.Px;
41 
42 import com.android.launcher3.DeviceProfile;
43 import com.android.launcher3.R;
44 import com.android.launcher3.Utilities;
45 import com.android.launcher3.model.WidgetItem;
46 import com.android.launcher3.model.data.PackageItemInfo;
47 import com.android.launcher3.recyclerview.ViewHolderBinder;
48 import com.android.launcher3.util.PackageUserKey;
49 import com.android.launcher3.widget.WidgetCell;
50 import com.android.launcher3.widget.model.WidgetsListBaseEntry;
51 import com.android.launcher3.widget.model.WidgetsListContentEntry;
52 import com.android.launcher3.widget.model.WidgetsListHeaderEntry;
53 
54 import java.util.Collections;
55 import java.util.List;
56 
57 /**
58  * Popup for showing the full list of available widgets with a two-pane layout.
59  */
60 public class WidgetsTwoPaneSheet extends WidgetsFullSheet {
61 
62     private static final int PERSONAL_TAB = 0;
63     private static final int WORK_TAB = 1;
64     private static final int MINIMUM_WIDTH_LEFT_PANE_FOLDABLE_DP = 268;
65     private static final int MAXIMUM_WIDTH_LEFT_PANE_FOLDABLE_DP = 395;
66     private static final String SUGGESTIONS_PACKAGE_NAME = "widgets_list_suggestions_entry";
67 
68     // This ratio defines the max percentage of content area that the recommendations can display
69     // with respect to the bottom sheet's height.
70     private static final float RECOMMENDATION_SECTION_HEIGHT_RATIO_TWO_PANE = 0.70f;
71     private FrameLayout mSuggestedWidgetsContainer;
72     private WidgetsListHeader mSuggestedWidgetsHeader;
73     private PackageUserKey mSuggestedWidgetsPackageUserKey;
74     private View mPrimaryWidgetListView;
75     private LinearLayout mRightPane;
76 
77     private ScrollView mRightPaneScrollView;
78     private WidgetsListTableViewHolderBinder mWidgetsListTableViewHolderBinder;
79 
80     private boolean mOldIsSwipeToDismissInProgress;
81     private int mActivePage = -1;
82     @Nullable
83     private PackageUserKey mSelectedHeader;
84 
WidgetsTwoPaneSheet(Context context, AttributeSet attrs, int defStyleAttr)85     public WidgetsTwoPaneSheet(Context context, AttributeSet attrs, int defStyleAttr) {
86         super(context, attrs, defStyleAttr);
87     }
88 
WidgetsTwoPaneSheet(Context context, AttributeSet attrs)89     public WidgetsTwoPaneSheet(Context context, AttributeSet attrs) {
90         super(context, attrs);
91     }
92 
93     @Override
setupSheet()94     protected void setupSheet() {
95         // Set the header change listener in the adapter
96         mAdapters.get(AdapterHolder.PRIMARY)
97                 .mWidgetsListAdapter.setHeaderChangeListener(getHeaderChangeListener());
98         mAdapters.get(AdapterHolder.WORK)
99                 .mWidgetsListAdapter.setHeaderChangeListener(getHeaderChangeListener());
100         mAdapters.get(AdapterHolder.SEARCH)
101                 .mWidgetsListAdapter.setHeaderChangeListener(getHeaderChangeListener());
102 
103         LayoutInflater layoutInflater = LayoutInflater.from(getContext());
104 
105         int contentLayoutRes = mHasWorkProfile ? R.layout.widgets_two_pane_sheet_paged_view
106                 : R.layout.widgets_two_pane_sheet_recyclerview;
107         layoutInflater.inflate(contentLayoutRes, findViewById(R.id.recycler_view_container), true);
108 
109         setupViews();
110 
111         mWidgetsListTableViewHolderBinder =
112                 new WidgetsListTableViewHolderBinder(mActivityContext, layoutInflater, this, this);
113 
114         mWidgetRecommendationsContainer = mContent.findViewById(
115                 R.id.widget_recommendations_container);
116         mWidgetRecommendationsView = mContent.findViewById(
117                 R.id.widget_recommendations_view);
118         mWidgetRecommendationsView.initParentViews(mWidgetRecommendationsContainer);
119         mWidgetRecommendationsView.setWidgetCellLongClickListener(this);
120         mWidgetRecommendationsView.setWidgetCellOnClickListener(this);
121         // To save the currently displayed page, so that, it can be requested when rebinding
122         // recommendations with different size constraints.
123         mWidgetRecommendationsView.addPageSwitchListener(
124                 newPage -> mRecommendationsCurrentPage = newPage);
125 
126         mHeaderTitle = mContent.findViewById(R.id.title);
127         mRightPane = mContent.findViewById(R.id.right_pane);
128         mRightPaneScrollView = mContent.findViewById(R.id.right_pane_scroll_view);
129         mRightPaneScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER);
130 
131         mPrimaryWidgetListView = findViewById(R.id.primary_widgets_list_view);
132         mPrimaryWidgetListView.setOutlineProvider(mViewOutlineProvider);
133         mPrimaryWidgetListView.setClipToOutline(true);
134 
135         onWidgetsBound();
136 
137         // Set the fast scroller as not visible for two pane layout.
138         mFastScroller.setVisibility(GONE);
139     }
140 
141     @Override
getTabletHorizontalMargin(DeviceProfile deviceProfile)142     protected int getTabletHorizontalMargin(DeviceProfile deviceProfile) {
143         if (enableCategorizedWidgetSuggestions()) {
144             // two pane picker is full width for fold as well as tablet.
145             return getResources().getDimensionPixelSize(
146                     R.dimen.widget_picker_two_panels_left_right_margin);
147         }
148         if (deviceProfile.isTwoPanels && enableUnfoldedTwoPanePicker()) {
149             // enableUnfoldedTwoPanePicker made two pane picker full-width for fold only.
150             return getResources().getDimensionPixelSize(
151                     R.dimen.widget_picker_two_panels_left_right_margin);
152         }
153         if (deviceProfile.isLandscape && !deviceProfile.isTwoPanels) {
154             // non-fold tablet landscape margins (ag/22163531)
155             return getResources().getDimensionPixelSize(
156                     R.dimen.widget_picker_landscape_tablet_left_right_margin);
157         }
158         return deviceProfile.allAppsLeftRightMargin;
159     }
160 
161     @Override
onUserSwipeToDismissProgressChanged()162     protected void onUserSwipeToDismissProgressChanged() {
163         super.onUserSwipeToDismissProgressChanged();
164         boolean isSwipeToDismissInProgress = mSwipeToDismissProgress.value > 0;
165         if (isSwipeToDismissInProgress == mOldIsSwipeToDismissInProgress) {
166             return;
167         }
168         mOldIsSwipeToDismissInProgress = isSwipeToDismissInProgress;
169         if (isSwipeToDismissInProgress) {
170             modifyAttributesOnViewTree(mPrimaryWidgetListView, (ViewParent) mContent,
171                     CLIP_CHILDREN_FALSE_MODIFIER);
172             modifyAttributesOnViewTree(mRightPaneScrollView,  (ViewParent) mContent,
173                     CLIP_CHILDREN_FALSE_MODIFIER, CLIP_TO_PADDING_FALSE_MODIFIER);
174         } else {
175             restoreAttributesOnViewTree(mPrimaryWidgetListView, mContent,
176                     CLIP_CHILDREN_FALSE_MODIFIER);
177             restoreAttributesOnViewTree(mRightPaneScrollView, mContent,
178                     CLIP_CHILDREN_FALSE_MODIFIER, CLIP_TO_PADDING_FALSE_MODIFIER);
179         }
180     }
181 
182     @Override
onLayout(boolean changed, int l, int t, int r, int b)183     protected void onLayout(boolean changed, int l, int t, int r, int b) {
184         super.onLayout(changed, l, t, r, b);
185         if (changed && mDeviceProfile.isTwoPanels && enableUnfoldedTwoPanePicker()) {
186             LinearLayout layout = mContent.findViewById(R.id.linear_layout_container);
187             FrameLayout leftPane = layout.findViewById(R.id.recycler_view_container);
188             LinearLayout.LayoutParams layoutParams = (LayoutParams) leftPane.getLayoutParams();
189             // Width is 1/3 of the sheet unless it's less than min width or max width
190             int leftPaneWidth = layout.getMeasuredWidth() / 3;
191             @Px int minLeftPaneWidthPx = Utilities.dpToPx(MINIMUM_WIDTH_LEFT_PANE_FOLDABLE_DP);
192             @Px int maxLeftPaneWidthPx = Utilities.dpToPx(MAXIMUM_WIDTH_LEFT_PANE_FOLDABLE_DP);
193             if (leftPaneWidth < minLeftPaneWidthPx) {
194                 layoutParams.width = minLeftPaneWidthPx;
195             } else if (leftPaneWidth > maxLeftPaneWidthPx) {
196                 layoutParams.width = maxLeftPaneWidthPx;
197             } else {
198                 layoutParams.width = 0;
199             }
200             layoutParams.weight = layoutParams.width == 0 ? 0.33F : 0;
201 
202             post(() -> {
203                 // The following calls all trigger requestLayout, so we post them to avoid
204                 // calling requestLayout during a layout pass. This also fixes the related warnings
205                 // in logcat.
206                 leftPane.setLayoutParams(layoutParams);
207                 requestApplyInsets();
208                 if (mSelectedHeader != null) {
209                     if (mSelectedHeader.equals(mSuggestedWidgetsPackageUserKey)) {
210                         mSuggestedWidgetsHeader.callOnClick();
211                     } else {
212                         getHeaderChangeListener().onHeaderChanged(mSelectedHeader);
213                     }
214                 }
215             });
216         }
217     }
218 
219     @Override
onWidgetsBound()220     public void onWidgetsBound() {
221         super.onWidgetsBound();
222         if (mRecommendedWidgetsCount == 0 && mSelectedHeader == null) {
223             mAdapters.get(mActivePage).mWidgetsListAdapter.selectFirstHeaderEntry();
224             mAdapters.get(mActivePage).mWidgetsRecyclerView.scrollToTop();
225         }
226     }
227 
228     @Override
onRecommendedWidgetsBound()229     public void onRecommendedWidgetsBound() {
230         super.onRecommendedWidgetsBound();
231 
232         if (mSuggestedWidgetsContainer == null && mRecommendedWidgetsCount > 0) {
233             setupSuggestedWidgets(LayoutInflater.from(getContext()));
234             mSuggestedWidgetsHeader.callOnClick();
235         } else if (mSelectedHeader != null
236                 && mSelectedHeader.equals(mSuggestedWidgetsPackageUserKey)) {
237             // Reselect widget if we are reloading recommendations while it is currently showing.
238             selectWidgetCell(mWidgetRecommendationsContainer, getLastSelectedWidgetItem());
239         }
240     }
241 
setupSuggestedWidgets(LayoutInflater layoutInflater)242     private void setupSuggestedWidgets(LayoutInflater layoutInflater) {
243         // Add suggested widgets.
244         mSuggestedWidgetsContainer = mSearchScrollView.findViewById(R.id.suggestions_header);
245 
246         // Inflate the suggestions header.
247         mSuggestedWidgetsHeader = (WidgetsListHeader) layoutInflater.inflate(
248                 R.layout.widgets_list_row_header_two_pane,
249                 mSuggestedWidgetsContainer,
250                 false);
251         mSuggestedWidgetsHeader.setExpanded(true);
252 
253         PackageItemInfo packageItemInfo = new PackageItemInfo(
254                 /* packageName= */ SUGGESTIONS_PACKAGE_NAME,
255                 Process.myUserHandle()) {
256             @Override
257             public boolean usingLowResIcon() {
258                 return false;
259             }
260         };
261         String suggestionsHeaderTitle = getContext().getString(
262                 R.string.suggested_widgets_header_title);
263         String suggestionsRightPaneTitle = getContext().getString(
264                 R.string.widget_picker_right_pane_accessibility_title, suggestionsHeaderTitle);
265         packageItemInfo.title = suggestionsHeaderTitle;
266         // Suggestions may update at run time. The widgets count on suggestions doesn't add any
267         // value, so, we don't show the count.
268         WidgetsListHeaderEntry widgetsListHeaderEntry = WidgetsListHeaderEntry.create(
269                         packageItemInfo,
270                         /*titleSectionName=*/ suggestionsHeaderTitle,
271                         /*items=*/ mActivityContext.getPopupDataProvider().getRecommendedWidgets(),
272                         /*visibleWidgetsCount=*/ 0)
273                 .withWidgetListShown();
274 
275         mSuggestedWidgetsHeader.applyFromItemInfoWithIcon(widgetsListHeaderEntry);
276         mSuggestedWidgetsHeader.setIcon(
277                 getContext().getDrawable(R.drawable.widget_suggestions_icon));
278         mSuggestedWidgetsHeader.setOnClickListener(view -> {
279             mSuggestedWidgetsHeader.setExpanded(true);
280             resetExpandedHeaders();
281             mRightPane.removeAllViews();
282             mRightPane.addView(mWidgetRecommendationsContainer);
283             mRightPaneScrollView.setScrollY(0);
284             mRightPane.setAccessibilityPaneTitle(suggestionsRightPaneTitle);
285             mSuggestedWidgetsPackageUserKey = PackageUserKey.fromPackageItemInfo(packageItemInfo);
286             final boolean isChangingHeaders = mSelectedHeader == null
287                     || !mSelectedHeader.equals(mSuggestedWidgetsPackageUserKey);
288             if (isChangingHeaders)  {
289                 // If switching from another header, unselect any WidgetCells. This is necessary
290                 // because we do not clear/recycle the WidgetCells in the recommendations container
291                 // when the header is clicked, only when onRecommendationsBound is called. That
292                 // means a WidgetCell in the recommendations container may still be selected from
293                 // the last time the recommendations were shown.
294                 unselectWidgetCell(mWidgetRecommendationsContainer, getLastSelectedWidgetItem());
295             }
296             mSelectedHeader = mSuggestedWidgetsPackageUserKey;
297         });
298         mSuggestedWidgetsContainer.addView(mSuggestedWidgetsHeader);
299         mRightPane.setAccessibilityPaneTitle(suggestionsRightPaneTitle);
300     }
301 
302     @Override
303     @Px
getMaxAvailableHeightForRecommendations()304     protected float getMaxAvailableHeightForRecommendations() {
305         if (mRecommendedWidgetsCount > 0) {
306             // If widgets were already selected for display, we show them all on orientation change
307             // in a two pane picker
308             return Float.MAX_VALUE;
309         }
310 
311         return (mDeviceProfile.heightPx - mDeviceProfile.bottomSheetTopPadding)
312                 * RECOMMENDATION_SECTION_HEIGHT_RATIO_TWO_PANE;
313     }
314 
315     @Override
onActivePageChanged(int currentActivePage)316     public void onActivePageChanged(int currentActivePage) {
317         super.onActivePageChanged(currentActivePage);
318 
319         // If active page didn't change then we don't want to update the header.
320         if (mActivePage == currentActivePage) {
321             return;
322         }
323 
324         mActivePage = currentActivePage;
325 
326         if (mSuggestedWidgetsHeader == null) {
327             mAdapters.get(currentActivePage).mWidgetsListAdapter.selectFirstHeaderEntry();
328             mAdapters.get(currentActivePage).mWidgetsRecyclerView.scrollToTop();
329         } else if (currentActivePage == PERSONAL_TAB || currentActivePage == WORK_TAB) {
330             mSuggestedWidgetsHeader.callOnClick();
331         }
332     }
333 
334     @Override
updateRecyclerViewVisibility(AdapterHolder adapterHolder)335     protected void updateRecyclerViewVisibility(AdapterHolder adapterHolder) {
336         // The first item is always an empty space entry. Look for any more items.
337         boolean isWidgetAvailable = adapterHolder.mWidgetsListAdapter.hasVisibleEntries();
338 
339         mRightPane.setVisibility(isWidgetAvailable ? VISIBLE : GONE);
340 
341         super.updateRecyclerViewVisibility(adapterHolder);
342     }
343 
344     @Override
onSearchResults(List<WidgetsListBaseEntry> entries)345     public void onSearchResults(List<WidgetsListBaseEntry> entries) {
346         super.onSearchResults(entries);
347         mAdapters.get(AdapterHolder.SEARCH).mWidgetsListAdapter.selectFirstHeaderEntry();
348         mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView.scrollToTop();
349     }
350 
351     @Override
shouldScroll(MotionEvent ev)352     protected boolean shouldScroll(MotionEvent ev) {
353         return getPopupContainer().isEventOverView(mRightPaneScrollView, ev)
354                 ? mRightPaneScrollView.canScrollVertically(-1)
355                 : super.shouldScroll(ev);
356     }
357 
358     @Override
setViewVisibilityBasedOnSearch(boolean isInSearchMode)359     protected void setViewVisibilityBasedOnSearch(boolean isInSearchMode) {
360         super.setViewVisibilityBasedOnSearch(isInSearchMode);
361 
362         if (mSuggestedWidgetsHeader != null && mSuggestedWidgetsContainer != null) {
363             if (!isInSearchMode) {
364                 mSuggestedWidgetsContainer.setVisibility(VISIBLE);
365                 mSuggestedWidgetsHeader.callOnClick();
366             } else {
367                 mSuggestedWidgetsContainer.setVisibility(GONE);
368             }
369         } else if (!isInSearchMode) {
370             mAdapters.get(mActivePage).mWidgetsListAdapter.selectFirstHeaderEntry();
371         }
372 
373     }
374 
375     @Override
getContentView()376     protected View getContentView() {
377         return mRightPane;
378     }
379 
getHeaderChangeListener()380     private HeaderChangeListener getHeaderChangeListener() {
381         return new HeaderChangeListener() {
382             @Override
383             public void onHeaderChanged(@NonNull PackageUserKey selectedHeader) {
384                 final boolean isSameHeader = mSelectedHeader != null
385                         && mSelectedHeader.equals(selectedHeader);
386                 mSelectedHeader = selectedHeader;
387                 WidgetsListContentEntry contentEntry = mActivityContext.getPopupDataProvider()
388                         .getSelectedAppWidgets(selectedHeader);
389 
390                 if (contentEntry == null || mRightPane == null) {
391                     return;
392                 }
393 
394                 if (mSuggestedWidgetsHeader != null) {
395                     mSuggestedWidgetsHeader.setExpanded(false);
396                 }
397 
398                 WidgetsListContentEntry contentEntryToBind;
399                 if (enableCategorizedWidgetSuggestions()) {
400                     // Setting max span size enables row to understand how to fit more than one item
401                     // in a row.
402                     contentEntryToBind = contentEntry.withMaxSpanSize(mMaxSpanPerRow);
403                 } else {
404                     contentEntryToBind = contentEntry;
405                 }
406 
407                 WidgetsRowViewHolder widgetsRowViewHolder =
408                         mWidgetsListTableViewHolderBinder.newViewHolder(mRightPane);
409                 mWidgetsListTableViewHolderBinder.bindViewHolder(widgetsRowViewHolder,
410                         contentEntryToBind,
411                         ViewHolderBinder.POSITION_FIRST | ViewHolderBinder.POSITION_LAST,
412                         Collections.EMPTY_LIST);
413                 if (isSameHeader) {
414                     // Reselect the last selected widget if we are reloading the same header.
415                     selectWidgetCell(widgetsRowViewHolder.tableContainer,
416                             getLastSelectedWidgetItem());
417                 }
418                 widgetsRowViewHolder.mDataCallback = data -> {
419                     mWidgetsListTableViewHolderBinder.bindViewHolder(widgetsRowViewHolder,
420                             contentEntryToBind,
421                             ViewHolderBinder.POSITION_FIRST | ViewHolderBinder.POSITION_LAST,
422                             Collections.singletonList(data));
423                     if (isSameHeader) {
424                         selectWidgetCell(widgetsRowViewHolder.tableContainer,
425                                 getLastSelectedWidgetItem());
426                     }
427                 };
428                 mRightPane.removeAllViews();
429                 mRightPane.addView(widgetsRowViewHolder.itemView);
430                 mRightPaneScrollView.setScrollY(0);
431                 mRightPane.setAccessibilityPaneTitle(
432                         getContext().getString(
433                                 R.string.widget_picker_right_pane_accessibility_title,
434                                 contentEntry.mPkgItem.title));
435             }
436         };
437     }
438 
439     private static void selectWidgetCell(ViewGroup parent, WidgetItem item) {
440         if (parent == null || item == null) return;
441         WidgetCell cell = Utilities.findViewByPredicate(parent, v -> v instanceof WidgetCell wc
442                 && wc.matchesItem(item));
443         if (cell != null && !cell.isShowingAddButton()) {
444             cell.callOnClick();
445         }
446     }
447 
448     private static void unselectWidgetCell(ViewGroup parent, WidgetItem item) {
449         if (parent == null || item == null) return;
450         WidgetCell cell = Utilities.findViewByPredicate(parent, v -> v instanceof WidgetCell wc
451                 && wc.matchesItem(item));
452         if (cell != null && cell.isShowingAddButton()) {
453             cell.hideAddButton(/* animate= */ false);
454         }
455     }
456 
457     @Override
458     public void setInsets(Rect insets) {
459         super.setInsets(insets);
460         FrameLayout rightPaneContainer = mContent.findViewById(R.id.right_pane_container);
461         rightPaneContainer.setPadding(
462                 rightPaneContainer.getPaddingLeft(),
463                 rightPaneContainer.getPaddingTop(),
464                 rightPaneContainer.getPaddingRight(),
465                 mBottomPadding);
466         requestLayout();
467     }
468 
469     @Override
470     protected int getWidgetListHorizontalMargin() {
471         return getResources().getDimensionPixelSize(
472                 R.dimen.widget_list_left_pane_horizontal_margin);
473     }
474 
475     @Override
476     protected boolean isTwoPane() {
477         return true;
478     }
479 
480     @Override
481     protected int getHeaderTopClip(@NonNull WidgetCell cell) {
482         return 0;
483     }
484 
485     @Override
486     protected void scrollCellContainerByY(WidgetCell wc, int scrollByY) {
487         for (ViewParent parent = wc.getParent(); parent != null; parent = parent.getParent()) {
488             if (parent instanceof ScrollView scrollView) {
489                 scrollView.smoothScrollBy(0, scrollByY);
490                 return;
491             } else if (parent == this) {
492                 return;
493             }
494         }
495     }
496 
497     /**
498      * This is a listener for when the selected header gets changed in the left pane.
499      */
500     public interface HeaderChangeListener {
501         /**
502          * Sets the right pane to have the widgets for the currently selected header from
503          * the left pane.
504          */
505         void onHeaderChanged(@NonNull PackageUserKey selectedHeader);
506     }
507 }
508