diff --git a/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java b/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java index 525e10773e70..b71ff27dbd6d 100644 --- a/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java +++ b/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java @@ -165,7 +165,7 @@ public void showFiles(boolean onDeviceOnly) { private void setupContent() { emptyContentIcon.setImageResource(R.drawable.ic_activity_light_grey); emptyContentProgressBar.getIndeterminateDrawable().setColorFilter(ThemeUtils.primaryAccentColor(this), - PorterDuff.Mode.SRC_IN); + PorterDuff.Mode.SRC_IN); FileDataStorageManager storageManager = new FileDataStorageManager(getAccount(), getContentResolver()); adapter = new ActivityListAdapter(this, getUserAccountManager(), this, storageManager, getCapabilities(), false); @@ -174,6 +174,7 @@ private void setupContent() { LinearLayoutManager layoutManager = new LinearLayoutManager(this); recyclerView.setLayoutManager(layoutManager); + recyclerView.addItemDecoration(new StickyHeaderItemDecoration(adapter)); recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override @@ -186,7 +187,7 @@ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { // synchronize loading state when item count changes if (!isLoadingActivities && (totalItemCount - visibleItemCount) <= (firstVisibleItemIndex + 5) - && nextPageUrl != null && !nextPageUrl.isEmpty()) { + && nextPageUrl != null && !nextPageUrl.isEmpty()) { // Almost reached the end, continue to load new activities mActionListener.loadActivities(nextPageUrl); } diff --git a/src/main/java/com/owncloud/android/ui/activities/StickyHeaderItemDecoration.java b/src/main/java/com/owncloud/android/ui/activities/StickyHeaderItemDecoration.java new file mode 100644 index 000000000000..bdcf81795acd --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/activities/StickyHeaderItemDecoration.java @@ -0,0 +1,122 @@ +/* + * Nextcloud Android client application + * + + * Copyright (C) 2019 Sevastyan Savanyuk + * Copyright (C) 2019 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.owncloud.android.ui.activities; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.owncloud.android.ui.adapter.StickyHeaderAdapter; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +public class StickyHeaderItemDecoration extends RecyclerView.ItemDecoration { + private final StickyHeaderAdapter adapter; + + + public StickyHeaderItemDecoration(StickyHeaderAdapter stickyHeaderAdapter) { + this.adapter = stickyHeaderAdapter; + } + + @Override + public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + super.onDrawOver(canvas, parent, state); + + View topChild = parent.getChildAt(0); + if (topChild == null) { + return; + } + int topChildPosition = parent.getChildAdapterPosition(topChild); + + if (topChildPosition == RecyclerView.NO_POSITION) { + return; + } + View currentHeader = getHeaderViewForItem(topChildPosition, parent); + fixLayoutSize(parent, currentHeader); + int contactPoint = currentHeader.getBottom(); + View childInContact = getChildInContact(parent, contactPoint); + + if (childInContact == null) { + return; + } + + if (adapter.isHeader(parent.getChildAdapterPosition(childInContact))) { + moveHeader(canvas, currentHeader, childInContact); + return; + } + + drawHeader(canvas, currentHeader); + } + + private void drawHeader(Canvas canvas, View header) { + canvas.save(); + canvas.translate(0, 0); + header.draw(canvas); + canvas.restore(); + } + + private void moveHeader(Canvas canvas, View currentHeader, View nextHeader) { + canvas.save(); + canvas.translate(0, nextHeader.getTop() - currentHeader.getHeight()); + currentHeader.draw(canvas); + canvas.restore(); + } + + private View getChildInContact(RecyclerView parent, int contactPoint) { + View childInContact = null; + for (int i = 0; i < parent.getChildCount(); i++) { + View currentChild = parent.getChildAt(i); + if (currentChild.getBottom() > contactPoint && currentChild.getTop() <= contactPoint) { + childInContact = currentChild; + break; + } + } + return childInContact; + } + + private View getHeaderViewForItem(int itemPosition, RecyclerView parent) { + int headerPosition = adapter.getHeaderPositionForItem(itemPosition); + int layoutId = adapter.getHeaderLayout(itemPosition); + View header = LayoutInflater.from(parent.getContext()).inflate(layoutId, parent, false); + header.setBackgroundColor(Color.WHITE); + adapter.bindHeaderData(header, headerPosition); + return header; + } + + private void fixLayoutSize(ViewGroup parent, View view) { + + // Specs for parent (RecyclerView) + int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); + + // Specs for children (headers) + int childWidthSpec = ViewGroup.getChildMeasureSpec(widthSpec, parent.getPaddingLeft() + parent.getPaddingRight(), view.getLayoutParams().width); + int childHeightSpec = ViewGroup.getChildMeasureSpec(heightSpec, parent.getPaddingTop() + parent.getPaddingBottom(), view.getLayoutParams().height); + + view.measure(childWidthSpec, childHeightSpec); + view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); + } + + +} diff --git a/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java b/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java index 35cd3420a366..a7f38cdae723 100644 --- a/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java +++ b/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java @@ -84,7 +84,7 @@ /** * Adapter for the activity view */ -public class ActivityListAdapter extends RecyclerView.Adapter { +public class ActivityListAdapter extends RecyclerView.Adapter implements StickyHeaderAdapter { static final int HEADER_TYPE = 100; static final int ACTIVITY_TYPE = 101; @@ -173,11 +173,10 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int positi } if (activity.getRichSubjectElement() != null && - !TextUtils.isEmpty(activity.getRichSubjectElement().getRichSubject())) { + !TextUtils.isEmpty(activity.getRichSubjectElement().getRichSubject())) { activityViewHolder.subject.setVisibility(View.VISIBLE); activityViewHolder.subject.setMovementMethod(LinkMovementMethod.getInstance()); - activityViewHolder.subject.setText(addClickablePart(activity.getRichSubjectElement()), - TextView.BufferType.SPANNABLE); + activityViewHolder.subject.setText(addClickablePart(activity.getRichSubjectElement()), TextView.BufferType.SPANNABLE); activityViewHolder.subject.setVisibility(View.VISIBLE); } else if (!TextUtils.isEmpty(activity.getSubject())) { activityViewHolder.subject.setVisibility(View.VISIBLE); @@ -198,8 +197,7 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int positi } if (activity.getRichSubjectElement() != null && - activity.getRichSubjectElement().getRichObjectList().size() > 0) { - + activity.getRichSubjectElement().getRichObjectList().size() > 0) { activityViewHolder.list.setVisibility(View.VISIBLE); activityViewHolder.list.removeAllViews(); @@ -325,23 +323,23 @@ private void setBitmap(OCFile file, ImageView fileIcon, boolean isDetailView) { private void downloadIcon(String icon, ImageView itemViewType) { GenericRequestBuilder requestBuilder = Glide.with(context) - .using(Glide.buildStreamModelLoader(Uri.class, context), InputStream.class) - .from(Uri.class) - .as(SVG.class) - .transcode(new SvgDrawableTranscoder(), PictureDrawable.class) - .sourceEncoder(new StreamEncoder()) - .cacheDecoder(new FileToStreamDecoder<>(new SvgDecoder())) - .decoder(new SvgDecoder()) - .placeholder(R.drawable.ic_activity) - .error(R.drawable.ic_activity) - .animate(android.R.anim.fade_in) - .listener(new SvgSoftwareLayerSetter<>()); + .using(Glide.buildStreamModelLoader(Uri.class, context), InputStream.class) + .from(Uri.class) + .as(SVG.class) + .transcode(new SvgDrawableTranscoder(), PictureDrawable.class) + .sourceEncoder(new StreamEncoder()) + .cacheDecoder(new FileToStreamDecoder<>(new SvgDecoder())) + .decoder(new SvgDecoder()) + .placeholder(R.drawable.ic_activity) + .error(R.drawable.ic_activity) + .animate(android.R.anim.fade_in) + .listener(new SvgSoftwareLayerSetter<>()); Uri uri = Uri.parse(icon); requestBuilder - .diskCacheStrategy(DiskCacheStrategy.SOURCE) - .load(uri) - .into(itemViewType); + .diskCacheStrategy(DiskCacheStrategy.SOURCE) + .load(uri) + .into(itemViewType); } private SpannableStringBuilder addClickablePart(RichElement richElement) { @@ -418,7 +416,7 @@ private int getThumbnailDimension() { CharSequence getHeaderDateString(Context context, long modificationTimestamp) { if ((System.currentTimeMillis() - modificationTimestamp) < DateUtils.WEEK_IN_MILLIS) { return DisplayUtils.getRelativeDateTimeString(context, modificationTimestamp, DateUtils.DAY_IN_MILLIS, - DateUtils.WEEK_IN_MILLIS, 0); + DateUtils.WEEK_IN_MILLIS, 0); } else { String pattern = "EEEE, MMMM d"; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR2) { @@ -428,6 +426,37 @@ CharSequence getHeaderDateString(Context context, long modificationTimestamp) { } } + + @Override + public int getHeaderPositionForItem(int itemPosition) { + int headerPosition = itemPosition; + while (headerPosition >= 0) { + if (this.isHeader(headerPosition)) { + break; + } + headerPosition -= 1; + } + return headerPosition; + } + + + @Override + public int getHeaderLayout(int headerPosition) { + return R.layout.activity_list_item_header; + } + + @Override + public void bindHeaderData(View header, int headerPosition) { + TextView textView = header.findViewById(R.id.title_header); + String headline = (String) values.get(headerPosition); + textView.setText(headline); + } + + @Override + public boolean isHeader(int itemPosition) { + return this.getItemViewType(itemPosition) == HEADER_TYPE; + } + protected class ActivityViewHolder extends RecyclerView.ViewHolder { private final ImageView activityIcon; diff --git a/src/main/java/com/owncloud/android/ui/adapter/StickyHeaderAdapter.java b/src/main/java/com/owncloud/android/ui/adapter/StickyHeaderAdapter.java new file mode 100644 index 000000000000..1ba23a9c27cf --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/adapter/StickyHeaderAdapter.java @@ -0,0 +1,56 @@ +/* + * Nextcloud Android client application + * + + * Copyright (C) 2019 Sevastyan Savanyuk + * Copyright (C) 2019 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.owncloud.android.ui.adapter; + +import android.view.View; + +import com.owncloud.android.ui.activities.StickyHeaderItemDecoration; + +public interface StickyHeaderAdapter { + /** + * This method gets called by {@link StickyHeaderItemDecoration} to fetch the position of the header item in the adapter + * that is used for (represents) item at specified position. + * @param itemPosition int. Adapter's position of the item for which to do the search of the position of the header item. + * @return int. Position of the header item in the adapter. + */ + int getHeaderPositionForItem(int itemPosition); + + /** + * This method gets called by {@link StickyHeaderItemDecoration} to get layout resource id for the header item at specified adapter's position. + * @param headerPosition int. Position of the header item in the adapter. + * @return int. Layout resource id. + */ + int getHeaderLayout(int headerPosition); + + /** + * This method gets called by {@link StickyHeaderItemDecoration} to setup the header View. + * @param header View. Header to set the data on. + * @param headerPosition int. Position of the header item in the adapter. + */ + void bindHeaderData(View header, int headerPosition); + + /** + * This method gets called by {@link StickyHeaderItemDecoration} to verify whether the item represents a header. + * @param itemPosition int. + * @return true, if item at the specified adapter's position represents a header. + */ + boolean isHeader(int itemPosition); +} diff --git a/src/main/res/layout/activity_list_item_header.xml b/src/main/res/layout/activity_list_item_header.xml index 3eee1c3f7526..6c8e3de862f7 100644 --- a/src/main/res/layout/activity_list_item_header.xml +++ b/src/main/res/layout/activity_list_item_header.xml @@ -6,11 +6,11 @@ - \ No newline at end of file + diff --git a/src/main/res/values/dims.xml b/src/main/res/values/dims.xml index 0806007d9558..1db7bb7af6b7 100644 --- a/src/main/res/values/dims.xml +++ b/src/main/res/values/dims.xml @@ -95,7 +95,7 @@ 32dp 24dp -3dp - 20sp + 16sp -3dp 48dp 32dp diff --git a/src/test/java/com/owncloud/android/ui/adapter/ActivityListAdapterTest.java b/src/test/java/com/owncloud/android/ui/adapter/ActivityListAdapterTest.java new file mode 100644 index 000000000000..dd7c5859baa8 --- /dev/null +++ b/src/test/java/com/owncloud/android/ui/adapter/ActivityListAdapterTest.java @@ -0,0 +1,137 @@ +/* + * Nextcloud Android client application + * + * @author Alex Plutta + * Copyright (C) 2019 Alex Plutta + * Copyright (C) 2019 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.owncloud.android.ui.adapter; + +import com.owncloud.android.lib.resources.activities.model.Activity; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.internal.util.reflection.FieldSetter; + +import java.util.ArrayList; + +public final class ActivityListAdapterTest { + + + @Mock + private ActivityListAdapter activityListAdapter; + + @Before + public void setUp() throws NoSuchFieldException { + MockitoAnnotations.initMocks(this); + MockitoAnnotations.initMocks(activityListAdapter); + FieldSetter.setField(activityListAdapter, activityListAdapter.getClass().getDeclaredField("values"), new ArrayList<>()); + } + + @Test + public void isHeader__ObjectIsHeader_ReturnTrue() { + Object header = "Hello"; + Object activity = Mockito.mock(Activity.class); + + Mockito.when(activityListAdapter.isHeader(0)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(0)).thenCallRealMethod(); + + activityListAdapter.values.add(header); + activityListAdapter.values.add(activity); + + final boolean result = activityListAdapter.isHeader(0); + Assert.assertTrue(result); + } + + @Test + public void isHeader__ObjectIsActivity_ReturnFalse() { + Object header = "Hello"; + Object activity = Mockito.mock(Activity.class); + + Mockito.when(activityListAdapter.isHeader(1)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(1)).thenCallRealMethod(); + + activityListAdapter.values.add(header); + activityListAdapter.values.add(activity); + Assert.assertFalse(activityListAdapter.isHeader(1)); + } + + @Test + public void getHeaderPositionForItem__AdapterIsEmpty_ReturnZero(){ + Mockito.when(activityListAdapter.isHeader(0)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(0)).thenCallRealMethod(); + + Assert.assertEquals(0,activityListAdapter.getHeaderPositionForItem(0)); + } + + @Test + public void getHeaderPositionForItem__ItemIsHeader_ReturnCurrentItem() { + Object header = "Hello"; + Object activity = Mockito.mock(Activity.class); + + Mockito.when(activityListAdapter.isHeader(0)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(0)).thenCallRealMethod(); + Mockito.when(activityListAdapter.isHeader(1)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(1)).thenCallRealMethod(); + Mockito.when(activityListAdapter.isHeader(2)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(2)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getHeaderPositionForItem(2)).thenCallRealMethod(); + Mockito.when(activityListAdapter.isHeader(3)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(3)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getHeaderPositionForItem(3)).thenCallRealMethod(); + + + activityListAdapter.values.add(header); + activityListAdapter.values.add(activity); + activityListAdapter.values.add(header); + activityListAdapter.values.add(activity); + + + Assert.assertEquals(2, activityListAdapter.getHeaderPositionForItem(2)); + + } + + @Test + public void getHeaderPositionForItem__ItemIsActivity_ReturnNextHeader() { + Object header = "Hello"; + Object activity = Mockito.mock(Activity.class); + + Mockito.when(activityListAdapter.isHeader(0)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(0)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getHeaderPositionForItem(0)).thenCallRealMethod(); + Mockito.when(activityListAdapter.isHeader(1)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(1)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getHeaderPositionForItem(1)).thenCallRealMethod(); + Mockito.when(activityListAdapter.isHeader(2)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(2)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getHeaderPositionForItem(2)).thenCallRealMethod(); + Mockito.when(activityListAdapter.isHeader(3)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getItemViewType(3)).thenCallRealMethod(); + Mockito.when(activityListAdapter.getHeaderPositionForItem(3)).thenCallRealMethod(); + + activityListAdapter.values.add(header); + activityListAdapter.values.add(activity); + activityListAdapter.values.add(header); + activityListAdapter.values.add(activity); + + Assert.assertEquals(2, activityListAdapter.getHeaderPositionForItem(2)); + } + +}