chromium/chrome/browser/history/java/src/org/chromium/chrome/browser/history/AppFilterCoordinator.java

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.history;

import android.content.Context;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.RecyclerView;

import org.chromium.components.browser_ui.bottomsheet.BottomSheetContent;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;
import org.chromium.ui.modelutil.SimpleRecyclerViewAdapter;

import java.util.List;

/** Coordinator class of the app filter bottom sheet UI for history page. */
class AppFilterCoordinator implements View.OnLayoutChangeListener {
    // Maximum number of app filter items shown on the sheet at once if screen dimension allows.
    static final int MAX_VISIBLE_ITEM_COUNT = 5;

    // Maximum ratio of the sheet height against the base view height.
    static final float MAX_SHEET_HEIGHT_RATIO = 0.7f;

    private final Context mContext;
    private final BottomSheetController mBottomSheetController;
    private final AppFilterMediator mMediator;
    private final RecyclerView mItemListView;
    private final BottomSheetContent mSheetContent;
    private final PropertyModel mCloseButtonModel;

    private final View mContentView;
    private final View mBaseView;

    private final CloseCallback mCloseCallback;
    private final int mAppCount;

    private int mBaseViewHeight;

    /** Data class for individual app item in the filter list. */
    public static class AppInfo {
        public final String id;
        public final Drawable icon;
        public final CharSequence label;

        public AppInfo(String id, Drawable icon, CharSequence label) {
            this.id = id;
            this.icon = icon;
            this.label = label;
        }

        /** Return whether the app info object is valid. */
        public boolean isValid() {
            return id != null;
        }

        @Override
        public boolean equals(Object o) {
            if (o == this) return true;
            return (o instanceof AppInfo appInfo) ? TextUtils.equals(id, appInfo.id) : false;
        }
    }

    /** Callback to be invoked when the sheet gets closed with updated app info. */
    public interface CloseCallback {
        /**
         * @param appInfo {@link AppInfo} containing the app information. May be {@code null} if no
         *     app is selected.
         */
        void onAppUpdated(AppInfo appInfo);
    }

    /**
     * Constructor.
     *
     * @param context {@link Context} for resources, views.
     * @param baseView Base view on which the sheet is opened.
     * @param bottomSheetController {@link BottomSheetController} to open/close the sheet.
     * @param closeCallback Callback invoked when the sheet is closed
     * @param appInfoList List of the apps to display in the sheet.
     */
    AppFilterCoordinator(
            Context context,
            View baseView,
            BottomSheetController bottomSheetController,
            CloseCallback closeCallback,
            List<AppInfo> appInfoList) {
        mContext = context;
        mBaseView = baseView;
        mBaseViewHeight = mBaseView.getHeight();
        mBottomSheetController = bottomSheetController;
        mCloseCallback = closeCallback;
        var layoutInflater = LayoutInflater.from(context);
        mContentView = layoutInflater.inflate(R.layout.appfilter_content, null);
        mItemListView = (RecyclerView) mContentView.findViewById(R.id.appfilter_item_list);
        mSheetContent =
                new AppFilterSheetContent(context, mContentView, mItemListView, this::destroy);

        ModelList listItems = new ModelList();
        var adapter = new SimpleRecyclerViewAdapter(listItems);
        adapter.registerType(
                0,
                (parent) ->
                        layoutInflater.inflate(
                                R.layout.modern_list_item_small_icon_view, parent, false),
                AppFilterViewBinder::bind);
        mItemListView.setAdapter(adapter);

        // Close button at the bottom.
        View closeButton = mContentView.findViewById(R.id.close_button);
        mCloseButtonModel =
                new PropertyModel.Builder(AppFilterProperties.CLOSE_BUTTON_KEY)
                        .with(
                                AppFilterProperties.CLOSE_BUTTON_CALLBACK,
                                v -> mBottomSheetController.hideContent(mSheetContent, true))
                        .build();
        PropertyModelChangeProcessor.create(
                mCloseButtonModel, closeButton, AppFilterViewBinder::bind);

        mMediator = new AppFilterMediator(context, listItems, appInfoList, this::closeSheet);
        mAppCount = listItems.size();
    }

    @Override
    public void onLayoutChange(
            View view,
            int left,
            int top,
            int right,
            int bottom,
            int oldLeft,
            int oldTop,
            int oldRight,
            int oldBottom) {
        if (!mBottomSheetController.isSheetOpen()) return;
        if (mBaseViewHeight != mBaseView.getHeight()) {
            mBaseViewHeight = mBaseView.getHeight();
            updateSheetHeight();
        }
    }

    /**
     * Open app filter bottom sheet.
     *
     * @param currentApp Initial app to be selected at the beginning. If {@code null}, no app will
     *     be selected.
     */
    public void openSheet(@Nullable AppInfo currentApp) {
        updateSheetHeight();
        mMediator.resetState(currentApp);
        mBottomSheetController.requestShowContent(mSheetContent, true);
    }

    /**
     * Update the sheet height. Called before opening it for the first time, or while it is open in
     * order to adjust the height if the base view layout change occurs.
     */
    private void updateSheetHeight() {
        ViewGroup.LayoutParams layoutParams = mItemListView.getLayoutParams();
        if (layoutParams == null) {
            layoutParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
        }

        int rowHeight =
                mContext.getResources().getDimensionPixelSize(R.dimen.min_touch_target_size);
        layoutParams.height = calculateSheetHeight(rowHeight, mBaseView.getHeight(), mAppCount);
        mItemListView.setLayoutParams(layoutParams);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static int calculateSheetHeight(int rowHeight, int baseViewHeight, int rowCount) {
        int maxHeight = (int) (baseViewHeight * MAX_SHEET_HEIGHT_RATIO);
        int visibleRowCount = Math.min(rowCount, MAX_VISIBLE_ITEM_COUNT);
        return Math.min(visibleRowCount * rowHeight, maxHeight);
    }

    private void closeSheet(AppInfo appInfo) {
        mBottomSheetController.hideContent(mSheetContent, true);
        mCloseCallback.onAppUpdated(appInfo);
    }

    private void destroy() {
        mBaseView.removeOnLayoutChangeListener(this);
    }

    void clickItemForTesting(String appId) {
        mMediator.clickItemForTesting(appId); // IN-TEST
    }

    void clickCloseButtonForTesting() {
        mCloseButtonModel.get(AppFilterProperties.CLOSE_BUTTON_CALLBACK).onClick(null); // IN-TEST
    }

    void setCurrentAppForTesting(String appId) {
        mMediator.setCurrentAppForTesting(appId); // IN-TEST
    }

    String getCurrentAppIdForTesting() {
        return mMediator.getCurrentAppIdForTesting(); // IN-TEST
    }
}