diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogFragment.java new file mode 100644 index 000000000..581af4ae5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogFragment.java @@ -0,0 +1,41 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipe.databinding.SearchFilterDialogFragmentBinding; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; + +/** + * A search filter dialog that also looks like a dialog aka. 'dialog style'. + */ +public class SearchFilterDialogFragment extends BaseSearchFilterDialogFragment { + + protected SearchFilterDialogFragmentBinding binding; + + @Override + protected BaseSearchFilterUiGenerator createSearchFilterDialogGenerator() { + return new SearchFilterDialogGenerator( + searchViewModel.getSearchFilterLogic(), binding.verticalScroll, requireContext()); + } + + @Override + @Nullable + protected Toolbar getToolbar() { + return binding.toolbarLayout.toolbar; + } + + @Override + protected View getRootView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container) { + binding = SearchFilterDialogFragmentBinding + .inflate(inflater, container, false); + return binding.getRoot(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java new file mode 100644 index 000000000..b1ee9c75f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java @@ -0,0 +1,337 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.GridLayout; +import android.widget.Spinner; +import android.widget.TextView; + +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class SearchFilterDialogGenerator extends BaseSearchFilterUiDialogGenerator { + private static final int CHIP_GROUP_ELEMENTS_THRESHOLD = 2; + private static final int CHIP_MIN_TOUCH_TARGET_SIZE_DP = 40; + protected final GridLayout globalLayout; + + public SearchFilterDialogGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final ViewGroup root, + @NonNull final Context context) { + super(logic, context); + this.globalLayout = createGridLayout(); + root.addView(globalLayout); + } + + @Override + protected void createTitle(@NonNull final String name, + @NonNull final List titleViewElements) { + final TextView titleView = createTitleText(name); + final View separatorLine = createSeparatorLine(); + final View separatorLine2 = createSeparatorLine(); + + globalLayout.addView(separatorLine); + globalLayout.addView(titleView); + globalLayout.addView(separatorLine2); + + titleViewElements.add(titleView); + titleViewElements.add(separatorLine); + titleViewElements.add(separatorLine2); + } + + @Override + protected void createFilterGroup(@NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate) { + final GridLayout.LayoutParams layoutParams = getLayoutParamsViews(); + boolean doSpanDataOverMultipleCells = false; + final UiItemWrapperViews viewsWrapper = new UiItemWrapperViews( + filterGroup.getIdentifier()); + + final TextView filterLabel; + if (filterGroup.getNameId() != null) { + filterLabel = createFilterLabel(filterGroup, layoutParams); + viewsWrapper.add(filterLabel); + } else { + filterLabel = null; + doSpanDataOverMultipleCells = true; + } + + if (filterGroup.isOnlyOneCheckable()) { + if (filterLabel != null) { + globalLayout.addView(filterLabel); + } + + final Spinner filterDataSpinner = new Spinner(context, Spinner.MODE_DROPDOWN); + + final GridLayout.LayoutParams spinnerLp = + clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells); + setDefaultMargin(spinnerLp); + filterDataSpinner.setLayoutParams(spinnerLp); + setZeroPadding(filterDataSpinner); + + createUiElementsForSingleSelectableItemsFilterGroup( + filterGroup, wrapperDelegate, selectorDelegate, filterDataSpinner); + + viewsWrapper.add(filterDataSpinner); + globalLayout.addView(filterDataSpinner); + + } else { // multiple items in FilterGroup selectable + final ChipGroup chipGroup = new ChipGroup(context); + doSpanDataOverMultipleCells = chooseParentViewForFilterLabelAndAdd( + filterGroup, doSpanDataOverMultipleCells, filterLabel, chipGroup); + + viewsWrapper.add(chipGroup); + globalLayout.addView(chipGroup); + chipGroup.setLayoutParams( + clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells)); + chipGroup.setSingleLine(false); + + createUiChipElementsForFilterGroupItems( + filterGroup, wrapperDelegate, selectorDelegate, chipGroup); + } + + wrapperDelegate.put(filterGroup.getIdentifier(), viewsWrapper); + } + + @NonNull + protected TextView createFilterLabel(@NonNull final FilterGroup filterGroup, + @NonNull final GridLayout.LayoutParams layoutParams) { + final TextView filterLabel; + filterLabel = new TextView(context); + + filterLabel.setId(filterGroup.getIdentifier()); + filterLabel.setText( + ServiceHelper.getTranslatedFilterString(filterGroup.getNameId(), context)); + filterLabel.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + setZeroPadding(filterLabel); + + filterLabel.setLayoutParams(layoutParams); + return filterLabel; + } + + private boolean chooseParentViewForFilterLabelAndAdd( + @NonNull final FilterGroup filterGroup, + final boolean doSpanDataOverMultipleCells, + @Nullable final TextView filterLabel, + @NonNull final ChipGroup possibleParentView) { + + boolean spanOverMultipleCells = doSpanDataOverMultipleCells; + if (filterLabel != null) { + // If we have more than CHIP_GROUP_ELEMENTS_THRESHOLD elements to be + // displayed as Chips add its filterLabel as first element to ChipGroup. + // Now the ChipGroup can be spanned over all the cells to use + // the space better. + if (filterGroup.getFilterItems().size() > CHIP_GROUP_ELEMENTS_THRESHOLD) { + possibleParentView.addView(filterLabel); + spanOverMultipleCells = true; + } else { + globalLayout.addView(filterLabel); + } + } + return spanOverMultipleCells; + } + + private void createUiElementsForSingleSelectableItemsFilterGroup( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate, + @NonNull final Spinner filterDataSpinner) { + filterDataSpinner.setAdapter(new SearchFilterDialogSpinnerAdapter( + context, filterGroup, wrapperDelegate, filterDataSpinner)); + + final AdapterView.OnItemSelectedListener listener; + listener = new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(final AdapterView parent, final View view, + final int position, final long id) { + if (view != null) { + selectorDelegate.selectFilter(view.getId()); + } + } + + @Override + public void onNothingSelected(final AdapterView parent) { + // we are only interested onItemSelected() -> no implementation here + } + }; + + filterDataSpinner.setOnItemSelectedListener(listener); + } + + protected void createUiChipElementsForFilterGroupItems( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate, + @NonNull final ChipGroup chipGroup) { + for (final FilterItem item : filterGroup.getFilterItems()) { + + if (item instanceof InjectFilterItem.DividerItem) { + final InjectFilterItem.DividerItem dividerItem = + (InjectFilterItem.DividerItem) item; + + // For the width MATCH_PARENT is necessary as this allows the + // dividerLabel to fill one row of ChipGroup exclusively + final ChipGroup.LayoutParams layoutParams = new ChipGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + final TextView dividerLabel = createDividerLabel(dividerItem, layoutParams); + chipGroup.addView(dividerLabel); + } else { + final Chip chip = createChipView(chipGroup, item); + + final View.OnClickListener listener; + listener = view -> selectorDelegate.selectFilter(view.getId()); + chip.setOnClickListener(listener); + + chipGroup.addView(chip); + wrapperDelegate.put(item.getIdentifier(), + new UiItemWrapperChip(item, chip, chipGroup)); + } + } + } + + @NonNull + private Chip createChipView(@NonNull final ChipGroup chipGroup, + @NonNull final FilterItem item) { + final Chip chip = (Chip) LayoutInflater.from(context).inflate( + R.layout.chip_search_filter, chipGroup, false); + chip.ensureAccessibleTouchTarget( + DeviceUtils.dpToPx(CHIP_MIN_TOUCH_TARGET_SIZE_DP, context)); + chip.setText(ServiceHelper.getTranslatedFilterString(item.getNameId(), context)); + chip.setId(item.getIdentifier()); + chip.setCheckable(true); + return chip; + } + + @NonNull + private TextView createDividerLabel( + @NonNull final InjectFilterItem.DividerItem dividerItem, + @NonNull final ViewGroup.MarginLayoutParams layoutParams) { + final TextView dividerLabel; + dividerLabel = new TextView(context); + dividerLabel.setEnabled(true); + + dividerLabel.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + dividerLabel.setLayoutParams(layoutParams); + final String menuDividerTitle = + context.getString(dividerItem.getStringResId()); + dividerLabel.setText(menuDividerTitle); + return dividerLabel; + } + + @NonNull + protected SeparatorLineView createSeparatorLine() { + return createSeparatorLine(clipFreeRightColumnLayoutParams(true)); + } + + @NonNull + private TextView createTitleText(final String name) { + final TextView title = createTitleText(name, + clipFreeRightColumnLayoutParams(true)); + title.setGravity(Gravity.CENTER); + return title; + } + + @NonNull + private GridLayout createGridLayout() { + final GridLayout layout = new GridLayout(context); + + layout.setColumnCount(2); + + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + setDefaultMargin(layoutParams); + layout.setLayoutParams(layoutParams); + + return layout; + } + + @NonNull + protected GridLayout.LayoutParams clipFreeRightColumnLayoutParams(final boolean doColumnSpan) { + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + // https://stackoverflow.com/questions/37744672/gridlayout-children-are-being-clipped + layoutParams.width = 0; + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.setGravity(Gravity.FILL_HORIZONTAL | Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + + if (doColumnSpan) { + layoutParams.columnSpec = GridLayout.spec(0, 2, 1.0f); + } + + return layoutParams; + } + + @NonNull + private GridLayout.LayoutParams getLayoutParamsViews() { + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + layoutParams.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + return layoutParams; + } + + @NonNull + protected ViewGroup.MarginLayoutParams setDefaultMargin( + @NonNull final ViewGroup.MarginLayoutParams layoutParams) { + layoutParams.setMargins( + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(2, context) + ); + return layoutParams; + } + + @NonNull + protected View setZeroPadding(@NonNull final View view) { + view.setPadding(0, 0, 0, 0); + return view; + } + + public static class UiItemWrapperChip extends BaseUiItemWrapper { + + @NonNull + private final ChipGroup chipGroup; + + public UiItemWrapperChip(@NonNull final FilterItem item, + @NonNull final View view, + @NonNull final ChipGroup chipGroup) { + super(item, view); + this.chipGroup = chipGroup; + } + + @Override + public boolean isChecked() { + return ((Chip) view).isChecked(); + } + + @Override + public void setChecked(final boolean checked) { + ((Chip) view).setChecked(checked); + + if (checked) { + chipGroup.check(view.getId()); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogSpinnerAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogSpinnerAdapter.java new file mode 100644 index 000000000..dd18dce78 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogSpinnerAdapter.java @@ -0,0 +1,224 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.SparseIntArray; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.Spinner; +import android.widget.TextView; + +import org.schabi.newpipe.extractor.search.filter.FilterContainer; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.Objects; + +import androidx.annotation.NonNull; +import androidx.collection.SparseArrayCompat; + +import static org.schabi.newpipe.fragments.list.search.filter.InjectFilterItem.DividerItem; + +public class SearchFilterDialogSpinnerAdapter extends BaseAdapter { + + private final Context context; + private final FilterGroup group; + private final BaseSearchFilterUiGenerator.UiWrapperMapDelegate wrapperDelegate; + private final Spinner spinner; + private final SparseIntArray id2PosMap = new SparseIntArray(); + private final SparseArrayCompat + viewWrapperMap = new SparseArrayCompat<>(); + + public SearchFilterDialogSpinnerAdapter( + @NonNull final Context context, + @NonNull final FilterGroup group, + @NonNull final BaseSearchFilterUiGenerator.UiWrapperMapDelegate wrapperDelegate, + @NonNull final Spinner filterDataSpinner) { + this.context = context; + this.group = group; + this.wrapperDelegate = wrapperDelegate; + this.spinner = filterDataSpinner; + + createViewWrappers(); + } + + @Override + public int getCount() { + return group.getFilterItems().size(); + } + + @Override + public Object getItem(final int position) { + return group.getFilterItems().get(position); + } + + @Override + public long getItemId(final int position) { + return position; + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + final FilterItem item = group.getFilterItems().get(position); + final TextView view; + + if (convertView != null) { + view = (TextView) convertView; + } else { + view = createViewItem(); + } + + initViewWithData(position, item, view); + return view; + } + + @SuppressLint("WrongConstant") + private void initViewWithData(final int position, + final FilterItem item, + final TextView view) { + final UiItemWrapperSpinner wrappedView = + viewWrapperMap.get(position); + Objects.requireNonNull(wrappedView); + + view.setId(item.getIdentifier()); + view.setText(ServiceHelper.getTranslatedFilterString(item.getNameId(), context)); + view.setVisibility(wrappedView.getVisibility()); + view.setEnabled(wrappedView.isEnabled()); + + if (item instanceof DividerItem) { + final DividerItem dividerItem = (DividerItem) item; + wrappedView.setEnabled(false); + view.setEnabled(wrappedView.isEnabled()); + final String menuDividerTitle = ">>>" + + context.getString(dividerItem.getStringResId()) + "<<<"; + view.setText(menuDividerTitle); + } + } + + private void createViewWrappers() { + int position = 0; + for (final FilterItem item : this.group.getFilterItems()) { + final int initialVisibility = View.VISIBLE; + final boolean isInitialEnabled = true; + + final UiItemWrapperSpinner wrappedView = + new UiItemWrapperSpinner( + item, + initialVisibility, + isInitialEnabled, + spinner); + + if (item instanceof DividerItem) { + wrappedView.setEnabled(false); + } + + // store wrapper also locally as we refer here regularly + viewWrapperMap.put(position, wrappedView); + // store wrapper globally in SearchFilterLogic + wrapperDelegate.put(item.getIdentifier(), wrappedView); + id2PosMap.put(item.getIdentifier(), position); + position++; + } + } + + @NonNull + private TextView createViewItem() { + final TextView view = new TextView(context); + view.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + view.setGravity(Gravity.CENTER_VERTICAL); + view.setPadding( + DeviceUtils.dpToPx(8, context), + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(8, context), + DeviceUtils.dpToPx(4, context) + ); + return view; + } + + public int getItemPositionForFilterId(final int id) { + return id2PosMap.get(id); + } + + @Override + public boolean isEnabled(final int position) { + final UiItemWrapperSpinner wrappedView = + viewWrapperMap.get(position); + Objects.requireNonNull(wrappedView); + return wrappedView.isEnabled(); + } + + private static class UiItemWrapperSpinner + extends BaseItemWrapper { + @NonNull + private final Spinner spinner; + + /** + * We have to store the visibility of the view and if it is enabled. + *

+ * Reason: the Spinner adapter reuses {@link View} elements through the parameter + * convertView in {@link SearchFilterDialogSpinnerAdapter#getView(int, View, ViewGroup)} + * -> this is the Android Adapter's time saving characteristic to rather reuse + * than to recreate a {@link View}. + * -> so we reuse what Android gives us in above mentioned method. + */ + private int visibility; + private boolean enabled; + + UiItemWrapperSpinner(@NonNull final FilterItem item, + final int initialVisibility, + final boolean isInitialEnabled, + @NonNull final Spinner spinner) { + super(item); + this.spinner = spinner; + + this.visibility = initialVisibility; + this.enabled = isInitialEnabled; + } + + @Override + public void setVisible(final boolean visible) { + if (visible) { + visibility = View.VISIBLE; + } else { + visibility = View.GONE; + } + } + + @Override + public boolean isChecked() { + return spinner.getSelectedItem() == item; + } + + @Override + public void setChecked(final boolean checked) { + if (super.getItemId() != FilterContainer.ITEM_IDENTIFIER_UNKNOWN) { + final SearchFilterDialogSpinnerAdapter adapter = + (SearchFilterDialogSpinnerAdapter) spinner.getAdapter(); + spinner.setSelection(adapter.getItemPositionForFilterId(super.getItemId())); + } + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + public int getVisibility() { + return visibility; + } + + public void setVisibility(final int visibility) { + this.visibility = visibility; + } + } +} diff --git a/app/src/main/res/color/mtrl_search_filter_chip_background_color.xml b/app/src/main/res/color/mtrl_search_filter_chip_background_color.xml new file mode 100644 index 000000000..1bea84d4e --- /dev/null +++ b/app/src/main/res/color/mtrl_search_filter_chip_background_color.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/chip_search_filter.xml b/app/src/main/res/layout/chip_search_filter.xml new file mode 100644 index 000000000..58fd1b5ab --- /dev/null +++ b/app/src/main/res/layout/chip_search_filter.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/layout/search_filter_dialog_fragment.xml b/app/src/main/res/layout/search_filter_dialog_fragment.xml new file mode 100644 index 000000000..8d375a04b --- /dev/null +++ b/app/src/main/res/layout/search_filter_dialog_fragment.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_search_filter_dialog_fragment.xml b/app/src/main/res/menu/menu_search_filter_dialog_fragment.xml new file mode 100644 index 000000000..7f0ec2009 --- /dev/null +++ b/app/src/main/res/menu/menu_search_filter_dialog_fragment.xml @@ -0,0 +1,15 @@ +

+ + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 164f10672..106f36f9c 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -155,4 +155,12 @@ +