diff --git a/build.gradle b/build.gradle index 43d71c338f..4c00f12fa7 100644 --- a/build.gradle +++ b/build.gradle @@ -59,8 +59,8 @@ dependencies { compile 'pl.tajchert:waitingdots:0.1.0' compile 'com.soundcloud.android:android-crop:0.9.10@aar' - compile 'com.android.support:appcompat-v7:22.1.1' - compile 'com.android.support:recyclerview-v7:21.0.3' + compile 'com.android.support:appcompat-v7:22.2.1' + compile 'com.android.support:recyclerview-v7:22.2.1' compile 'com.melnykov:floatingactionbutton:1.3.0' compile 'com.google.zxing:android-integration:3.1.0' compile ('com.android.support:support-v4-preferencefragment:1.0.0@aar'){ @@ -119,8 +119,8 @@ dependencyVerification { 'com.afollestad:material-dialogs:624dffff240533ca69414464f416c81c88c5c29689bb169093b9a333104f2471', 'pl.tajchert:waitingdots:2835d49e0787dbcb606c5a60021ced66578503b1e9fddcd7a5ef0cd5f095ba2c', 'com.soundcloud.android:android-crop:ffd4b973cf6e97f7d64118a0dc088df50e9066fd5634fe6911dd0c0c5d346177', - 'com.android.support:appcompat-v7:9a2355537c2f01cf0b95523605c18606b8d824017e6e94a05c77b0cfc8f21c96', - 'com.android.support:recyclerview-v7:e525ad3f33c84bb12b73d2dc975b55364a53f0f2d0697e043efba59ba73e22d2', + 'com.android.support:appcompat-v7:4b5ccba8c4557ef04f99aa0a80f8aa7d50f05f926a709010a54afd5c878d3618', + 'com.android.support:recyclerview-v7:b0f530a5b14334d56ce0de85527ffe93ac419bc928e2884287ce1dddfedfb505', 'com.melnykov:floatingactionbutton:15d58d4fac0f7a288d0e5301bbaf501a146f5b3f5921277811bf99bd3b397263', 'com.google.zxing:android-integration:89e56aadf1164bd71e57949163c53abf90af368b51669c0d4a47a163335f95c4', 'com.android.support:support-v4-preferencefragment:5470f5872514a6226fa1fc6f4e000991f38805691c534cf0bd2778911fc773ad', @@ -149,8 +149,8 @@ dependencyVerification { 'com.fasterxml.jackson.core:jackson-annotations:0ca408c24202a7626ec8b861e99d85eca5e38b73311dd6dd12e3e9deecc3fe94', 'com.fasterxml.jackson.core:jackson-core:cbf4604784b4de226262845447a1ad3bb38a6728cebe86562e2c5afada8be2c0', 'org.whispersystems:curve25519-java:9ccef8f5aba05d9942336f023c589d6278b4f9135bdc34a7bade1f4e7ad65fa3', - 'com.android.support:support-v4:1e2e4d35ac7fd30db5ce3bc177b92e4d5af86acef2ef93e9221599d733346f56', - 'com.android.support:support-annotations:7bc07519aa613b186001160403bcfd68260fa82c61cc7e83adeedc9b862b94ae', + 'com.android.support:support-v4:c62f0d025dafa86f423f48df9185b0d89496adbc5f6a9be5a7c394d84cf91423', + 'com.android.support:support-annotations:104f353b53d5dd8d64b2f77eece4b37f6b961de9732eb6b706395e91033ec70a', ] } diff --git a/res/drawable-hdpi/ic_badge_24dp.png b/res/drawable-hdpi/ic_badge_24dp.png new file mode 100644 index 0000000000..aaf4fba850 Binary files /dev/null and b/res/drawable-hdpi/ic_badge_24dp.png differ diff --git a/res/drawable-hdpi/ic_signal_grey_24dp.png b/res/drawable-hdpi/ic_signal_grey_24dp.png new file mode 100644 index 0000000000..e01390dad8 Binary files /dev/null and b/res/drawable-hdpi/ic_signal_grey_24dp.png differ diff --git a/res/drawable-hdpi/ic_signal_white_48dp.png b/res/drawable-hdpi/ic_signal_white_48dp.png new file mode 100644 index 0000000000..28ede6b2a5 Binary files /dev/null and b/res/drawable-hdpi/ic_signal_white_48dp.png differ diff --git a/res/drawable-mdpi/ic_badge_24dp.png b/res/drawable-mdpi/ic_badge_24dp.png new file mode 100644 index 0000000000..49b7c74eaf Binary files /dev/null and b/res/drawable-mdpi/ic_badge_24dp.png differ diff --git a/res/drawable-mdpi/ic_signal_grey_24dp.png b/res/drawable-mdpi/ic_signal_grey_24dp.png new file mode 100644 index 0000000000..2399fe616e Binary files /dev/null and b/res/drawable-mdpi/ic_signal_grey_24dp.png differ diff --git a/res/drawable-mdpi/ic_signal_white_48dp.png b/res/drawable-mdpi/ic_signal_white_48dp.png new file mode 100644 index 0000000000..6e9f3c3ed4 Binary files /dev/null and b/res/drawable-mdpi/ic_signal_white_48dp.png differ diff --git a/res/drawable-v12/recycler_view_fast_scroller_bubble.xml b/res/drawable-v12/recycler_view_fast_scroller_bubble.xml new file mode 100644 index 0000000000..f135c5c6d6 --- /dev/null +++ b/res/drawable-v12/recycler_view_fast_scroller_bubble.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable-xhdpi/ic_badge_24dp.png b/res/drawable-xhdpi/ic_badge_24dp.png new file mode 100644 index 0000000000..15fde7bffd Binary files /dev/null and b/res/drawable-xhdpi/ic_badge_24dp.png differ diff --git a/res/drawable-xhdpi/ic_signal_grey_24dp.png b/res/drawable-xhdpi/ic_signal_grey_24dp.png new file mode 100644 index 0000000000..9088e211df Binary files /dev/null and b/res/drawable-xhdpi/ic_signal_grey_24dp.png differ diff --git a/res/drawable-xhdpi/ic_signal_white_48dp.png b/res/drawable-xhdpi/ic_signal_white_48dp.png new file mode 100644 index 0000000000..189b9ecb44 Binary files /dev/null and b/res/drawable-xhdpi/ic_signal_white_48dp.png differ diff --git a/res/drawable-xxhdpi/ic_badge_24dp.png b/res/drawable-xxhdpi/ic_badge_24dp.png new file mode 100644 index 0000000000..e64872158a Binary files /dev/null and b/res/drawable-xxhdpi/ic_badge_24dp.png differ diff --git a/res/drawable-xxhdpi/ic_signal_grey_24dp.png b/res/drawable-xxhdpi/ic_signal_grey_24dp.png new file mode 100644 index 0000000000..d6c3d2bf5b Binary files /dev/null and b/res/drawable-xxhdpi/ic_signal_grey_24dp.png differ diff --git a/res/drawable-xxhdpi/ic_signal_white_48dp.png b/res/drawable-xxhdpi/ic_signal_white_48dp.png new file mode 100644 index 0000000000..a0b322b5bf Binary files /dev/null and b/res/drawable-xxhdpi/ic_signal_white_48dp.png differ diff --git a/res/drawable-xxxhdpi/ic_badge_24dp.png b/res/drawable-xxxhdpi/ic_badge_24dp.png new file mode 100644 index 0000000000..549a87dc66 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_badge_24dp.png differ diff --git a/res/drawable-xxxhdpi/ic_signal_grey_24dp.png b/res/drawable-xxxhdpi/ic_signal_grey_24dp.png new file mode 100644 index 0000000000..7df7b11d80 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_signal_grey_24dp.png differ diff --git a/res/drawable-xxxhdpi/ic_signal_white_48dp.png b/res/drawable-xxxhdpi/ic_signal_white_48dp.png new file mode 100644 index 0000000000..c17e9ae4d5 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_signal_white_48dp.png differ diff --git a/res/drawable/badge_drawable.xml b/res/drawable/badge_drawable.xml new file mode 100644 index 0000000000..5a87e3c781 --- /dev/null +++ b/res/drawable/badge_drawable.xml @@ -0,0 +1,6 @@ + + + diff --git a/res/drawable/recycler_view_fast_scroller_bubble.xml b/res/drawable/recycler_view_fast_scroller_bubble.xml new file mode 100644 index 0000000000..417f6e4bc6 --- /dev/null +++ b/res/drawable/recycler_view_fast_scroller_bubble.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/recycler_view_fast_scroller_handle.xml b/res/drawable/recycler_view_fast_scroller_handle.xml new file mode 100644 index 0000000000..fa2ca8a58a --- /dev/null +++ b/res/drawable/recycler_view_fast_scroller_handle.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/contact_selection_list_fragment.xml b/res/layout/contact_selection_list_fragment.xml index f54bcb98c8..a0c8e9f44a 100644 --- a/res/layout/contact_selection_list_fragment.xml +++ b/res/layout/contact_selection_list_fragment.xml @@ -1,5 +1,5 @@ - @@ -9,8 +9,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - @@ -23,4 +23,11 @@ android:textSize="20sp" /> - + + + + diff --git a/res/layout/contact_selection_list_item.xml b/res/layout/contact_selection_list_item.xml index 149350a7bd..ab4ab40aaa 100644 --- a/res/layout/contact_selection_list_item.xml +++ b/res/layout/contact_selection_list_item.xml @@ -1,72 +1,79 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="?android:attr/listPreferredItemHeight" + android:orientation="horizontal" + android:gravity="center_vertical" + android:background="@drawable/conversation_list_item_background" + android:paddingLeft="48dp" + android:paddingRight="20dp"> - - - + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + + + - - - + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:focusable="false" + android:clickable="false" /> diff --git a/res/layout/contact_selection_recyclerview_header.xml b/res/layout/contact_selection_recyclerview_header.xml new file mode 100644 index 0000000000..82cdc469e0 --- /dev/null +++ b/res/layout/contact_selection_recyclerview_header.xml @@ -0,0 +1,9 @@ + + diff --git a/res/layout/recycler_view_fast_scroller.xml b/res/layout/recycler_view_fast_scroller.xml new file mode 100644 index 0000000000..2edd24c130 --- /dev/null +++ b/res/layout/recycler_view_fast_scroller.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/res/values/attrs.xml b/res/values/attrs.xml index a36cc0a386..7657dc2565 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -129,7 +129,8 @@ - + + diff --git a/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java index bec8d3a138..bcb7c909a9 100644 --- a/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -18,30 +18,36 @@ package org.thoughtcrime.securesms; import android.database.Cursor; +import android.graphics.Canvas; +import android.graphics.Rect; import android.os.Build; +import android.os.Build.VERSION; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.v4.app.Fragment; import android.support.v4.app.LoaderManager; import android.support.v4.content.Loader; -import android.support.v4.widget.CursorAdapter; +import android.support.v4.view.ViewCompat; import android.support.v4.widget.SwipeRefreshLayout; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.AdapterView; import android.widget.TextView; +import org.thoughtcrime.securesms.components.RecyclerViewFastScroller; import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter; import org.thoughtcrime.securesms.contacts.ContactSelectionListItem; import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; +import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; +import org.thoughtcrime.securesms.util.ViewUtil; +import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; -import se.emilsjolander.stickylistheaders.StickyListHeadersListView; - /** * Fragment for selecting a one or more contacts from a list. * @@ -65,9 +71,10 @@ public class ContactSelectionListFragment extends Fragment private Map selectedContacts; private OnContactSelectedListener onContactSelectedListener; - private StickyListHeadersListView listView; private SwipeRefreshLayout swipeRefresh; private String cursorFilter; + private RecyclerView recyclerView; + private RecyclerViewFastScroller fastScroller; @Override public void onActivityCreated(Bundle icicle) { @@ -89,13 +96,12 @@ public class ContactSelectionListFragment extends Fragment public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false); - emptyText = (TextView) view.findViewById(android.R.id.empty); - swipeRefresh = (SwipeRefreshLayout) view.findViewById(R.id.swipe_refresh); - listView = (StickyListHeadersListView) view.findViewById(android.R.id.list); - listView.setFocusable(true); - listView.setFastScrollEnabled(true); - listView.setDrawingListUnderStickyHeader(false); - listView.setOnItemClickListener(new ListClickListener()); + emptyText = ViewUtil.findById(view, android.R.id.empty); + recyclerView = ViewUtil.findById(view, R.id.recycler_view); + swipeRefresh = ViewUtil.findById(view, R.id.swipe_refresh); + fastScroller = ViewUtil.findById(view, R.id.fast_scroller); + recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); + fastScroller.setRecyclerView(recyclerView); swipeRefresh.setEnabled(getActivity().getIntent().getBooleanExtra(REFRESHABLE, true) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN); @@ -117,9 +123,13 @@ public class ContactSelectionListFragment extends Fragment } private void initializeCursor() { - ContactSelectionListAdapter adapter = new ContactSelectionListAdapter(getActivity(), null, isMulti()); + ContactSelectionListAdapter adapter = new ContactSelectionListAdapter(getActivity(), + null, + new ListClickListener(), + isMulti()); selectedContacts = adapter.getSelectedContacts(); - listView.setAdapter(adapter); + recyclerView.setAdapter(adapter); + recyclerView.addItemDecoration(new StickyHeaderDecoration(adapter, true)); this.getLoaderManager().initLoader(0, null, this); } @@ -147,19 +157,18 @@ public class ContactSelectionListFragment extends Fragment @Override public void onLoadFinished(Loader loader, Cursor data) { - ((CursorAdapter) listView.getAdapter()).changeCursor(data); + ((CursorRecyclerViewAdapter) recyclerView.getAdapter()).changeCursor(data); emptyText.setText(R.string.contact_selection_group_activity__no_contacts); } @Override public void onLoaderReset(Loader loader) { - ((CursorAdapter) listView.getAdapter()).changeCursor(null); + ((CursorRecyclerViewAdapter) recyclerView.getAdapter()).changeCursor(null); } - private class ListClickListener implements AdapterView.OnItemClickListener { + private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener { @Override - public void onItemClick(AdapterView l, View v, int position, long id) { - ContactSelectionListItem contact = (ContactSelectionListItem)v; + public void onItemClick(ContactSelectionListItem contact) { if (!isMulti() || !selectedContacts.containsKey(contact.getContactId())) { selectedContacts.put(contact.getContactId(), contact.getNumber()); @@ -185,4 +194,181 @@ public class ContactSelectionListFragment extends Fragment void onContactSelected(String number); void onContactDeselected(String number); } + + /** + * A sticky header decoration for android's RecyclerView. + */ + public static class StickyHeaderDecoration extends RecyclerView.ItemDecoration { + + private Map mHeaderCache; + + private StickyHeaderAdapter mAdapter; + + private boolean mRenderInline; + + /** + * @param adapter the sticky header adapter to use + */ + public StickyHeaderDecoration(StickyHeaderAdapter adapter, boolean renderInline) { + mAdapter = adapter; + mHeaderCache = new HashMap<>(); + mRenderInline = renderInline; + } + + /** + * {@inheritDoc} + */ + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, + RecyclerView.State state) + { + int position = parent.getChildAdapterPosition(view); + + int headerHeight = 0; + if (position != RecyclerView.NO_POSITION && hasHeader(position)) { + View header = getHeader(parent, position).itemView; + headerHeight = getHeaderHeightForLayout(header); + } + + outRect.set(0, headerHeight, 0, 0); + } + + private boolean hasHeader(int position) { + if (position == 0) { + return true; + } + + int previous = position - 1; + return mAdapter.getHeaderId(position) != mAdapter.getHeaderId(previous); + } + + private RecyclerView.ViewHolder getHeader(RecyclerView parent, int position) { + final long key = mAdapter.getHeaderId(position); + + if (mHeaderCache.containsKey(key)) { + return mHeaderCache.get(key); + } else { + final RecyclerView.ViewHolder holder = mAdapter.onCreateHeaderViewHolder(parent); + final View header = holder.itemView; + + //noinspection unchecked + mAdapter.onBindHeaderViewHolder(holder, position); + + int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); + + int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, + parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width); + int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, + parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height); + + header.measure(childWidth, childHeight); + header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight()); + + mHeaderCache.put(key, holder); + + return holder; + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { + final int count = parent.getChildCount(); + + for (int layoutPos = 0; layoutPos < count; layoutPos++) { + final View child = parent.getChildAt(layoutPos); + + final int adapterPos = parent.getChildAdapterPosition(child); + + if (adapterPos != RecyclerView.NO_POSITION && (layoutPos == 0 || hasHeader(adapterPos))) { + View header = getHeader(parent, adapterPos).itemView; + c.save(); + final int left = child.getLeft(); + final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos); + c.translate(left, top); + header.draw(c); + c.restore(); + } + } + } + + private int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, + int layoutPos) + { + int headerHeight = getHeaderHeightForLayout(header); + int top = getChildY(parent, child) - headerHeight; + if (layoutPos == 0) { + final int count = parent.getChildCount(); + final long currentId = mAdapter.getHeaderId(adapterPos); + // find next view with header and compute the offscreen push if needed + for (int i = 1; i < count; i++) { + int adapterPosHere = parent.getChildAdapterPosition(parent.getChildAt(i)); + if (adapterPosHere != RecyclerView.NO_POSITION) { + long nextId = mAdapter.getHeaderId(adapterPosHere); + if (nextId != currentId) { + final View next = parent.getChildAt(i); + final int offset = getChildY(parent, next) - (headerHeight + getHeader(parent, adapterPosHere).itemView.getHeight()); + if (offset < 0) { + return offset; + } else { + break; + } + } + } + } + + top = Math.max(0, top); + } + + return top; + } + + private int getChildY(RecyclerView parent, View child) { + if (VERSION.SDK_INT < 11) { + Rect rect = new Rect(); + parent.getChildVisibleRect(child, rect, null); + return rect.top; + } else { + return (int)ViewCompat.getY(child); + } + } + + private int getHeaderHeightForLayout(View header) { + return mRenderInline ? 0 : header.getHeight(); + } + } + + /** + * The adapter to assist the {@link StickyHeaderDecoration} in creating and binding the header views. + * + * @param the header view holder + */ + public interface StickyHeaderAdapter { + + /** + * Returns the header id for the item at the given position. + * + * @param position the item position + * @return the header id + */ + long getHeaderId(int position); + + /** + * Creates a new header ViewHolder. + * + * @param parent the header's view parent + * @return a view holder for the created view + */ + T onCreateHeaderViewHolder(ViewGroup parent); + + /** + * Updates the header view to reflect the header data for the given position + * @param viewHolder the header view holder + * @param position the header's item position + */ + void onBindHeaderViewHolder(T viewHolder, int position); + } } diff --git a/src/org/thoughtcrime/securesms/components/AvatarImageView.java b/src/org/thoughtcrime/securesms/components/AvatarImageView.java index b97edadb59..6f7f462141 100644 --- a/src/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/src/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -3,8 +3,13 @@ package org.thoughtcrime.securesms.components; import android.content.Context; import android.content.Intent; import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.os.AsyncTask; import android.provider.ContactsContract; import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +import android.support.v4.util.Pair; import android.util.AttributeSet; import android.view.View; import android.widget.ImageView; @@ -16,10 +21,13 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactPhotoFactory; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientFactory; import org.thoughtcrime.securesms.recipients.Recipients; +import org.thoughtcrime.securesms.util.DirectoryHelper; +import org.thoughtcrime.securesms.util.DirectoryHelper.UserCapabilities.Capability; public class AvatarImageView extends ImageView { private boolean inverted; + private boolean showBadge; public AvatarImageView(Context context) { super(context); @@ -33,18 +41,22 @@ public class AvatarImageView extends ImageView { if (attrs != null) { TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AvatarImageView, 0, 0); inverted = typedArray.getBoolean(0, false); + showBadge = typedArray.getBoolean(1, false); typedArray.recycle(); } } - public void setAvatar(@Nullable Recipients recipients, boolean quickContactEnabled) { + public void setAvatar(final @Nullable Recipients recipients, boolean quickContactEnabled) { if (recipients != null) { MaterialColor backgroundColor = recipients.getColor(); setImageDrawable(recipients.getContactPhoto().asDrawable(getContext(), backgroundColor.toConversationColor(getContext()), inverted)); setAvatarClickHandler(recipients, quickContactEnabled); + setTag(recipients); + if (showBadge) new BadgeResolutionTask(getContext()).execute(recipients); } else { setImageDrawable(ContactPhotoFactory.getDefaultContactPhoto(null).asDrawable(getContext(), ContactColors.UNKNOWN_COLOR.toConversationColor(getContext()), inverted)); setOnClickListener(null); + setTag(null); } } @@ -74,4 +86,29 @@ public class AvatarImageView extends ImageView { } } + private class BadgeResolutionTask extends AsyncTask> { + private final Context context; + + public BadgeResolutionTask(Context context) { + this.context = context; + } + + @Override + protected Pair doInBackground(Recipients... recipients) { + Capability textCapability = DirectoryHelper.getUserCapabilities(context, recipients[0]).getTextCapability(); + return new Pair<>(recipients[0], textCapability == Capability.SUPPORTED); + } + + @Override + protected void onPostExecute(Pair result) { + if (getTag() == result.first && result.second) { + final Drawable badged = new LayerDrawable(new Drawable[] { + getDrawable(), + ContextCompat.getDrawable(context, R.drawable.badge_drawable) + }); + + setImageDrawable(badged); + } + } + } } diff --git a/src/org/thoughtcrime/securesms/components/RecyclerViewFastScroller.java b/src/org/thoughtcrime/securesms/components/RecyclerViewFastScroller.java new file mode 100644 index 0000000000..70c7336223 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/RecyclerViewFastScroller.java @@ -0,0 +1,206 @@ +/** + * Modified version of + * https://github.com/AndroidDeveloperLB/LollipopContactsRecyclerViewFastScroller + * + * Their license: + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thoughtcrime.securesms.components; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build.VERSION; +import android.support.annotation.NonNull; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class RecyclerViewFastScroller extends LinearLayout { + private static final int BUBBLE_ANIMATION_DURATION = 100; + private static final int TRACK_SNAP_RANGE = 5; + + private TextView bubble; + private View handle; + private RecyclerView recyclerView; + private int height; + private ObjectAnimator currentAnimator; + + private final RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { + if (bubble == null || handle.isSelected()) + return; + final int verticalScrollOffset = recyclerView.computeVerticalScrollOffset(); + final int verticalScrollRange = recyclerView.computeVerticalScrollRange(); + float proportion = (float)verticalScrollOffset / ((float)verticalScrollRange - height); + setBubbleAndHandlePosition(height * proportion); + } + }; + + public interface FastScrollAdapter { + CharSequence getBubbleText(int pos); + } + + public RecyclerViewFastScroller(final Context context) { + this(context, null); + } + + public RecyclerViewFastScroller(final Context context, final AttributeSet attrs) { + super(context, attrs); + setOrientation(HORIZONTAL); + setClipChildren(false); + inflate(context, R.layout.recycler_view_fast_scroller, this); + bubble = ViewUtil.findById(this, R.id.fastscroller_bubble); + handle = ViewUtil.findById(this, R.id.fastscroller_handle); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + height = h; + } + + @Override + @TargetApi(11) + public boolean onTouchEvent(@NonNull MotionEvent event) { + final int action = event.getAction(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (event.getX() < ViewUtil.getX(handle) - handle.getPaddingLeft() || + event.getY() < ViewUtil.getY(handle) - handle.getPaddingTop() || + event.getY() > ViewUtil.getY(handle) + handle.getHeight() + handle.getPaddingBottom()) + { + return false; + } + if (currentAnimator != null) { + currentAnimator.cancel(); + } + if (bubble.getVisibility() != VISIBLE) { + showBubble(); + } + handle.setSelected(true); + case MotionEvent.ACTION_MOVE: + final float y = event.getY(); + setBubbleAndHandlePosition(y); + setRecyclerViewPosition(y); + return true; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + handle.setSelected(false); + hideBubble(); + return true; + } + return super.onTouchEvent(event); + } + + public void setRecyclerView(final RecyclerView recyclerView) { + this.recyclerView = recyclerView; + recyclerView.addOnScrollListener(onScrollListener); + recyclerView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + recyclerView.getViewTreeObserver().removeOnPreDrawListener(this); + if (bubble == null || handle.isSelected()) + return true; + final int verticalScrollOffset = recyclerView.computeVerticalScrollOffset(); + final int verticalScrollRange = recyclerView.computeVerticalScrollRange(); + float proportion = (float)verticalScrollOffset / ((float)verticalScrollRange - height); + setBubbleAndHandlePosition(height * proportion); + return true; + } + }); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (recyclerView != null) + recyclerView.removeOnScrollListener(onScrollListener); + } + + private void setRecyclerViewPosition(float y) { + if (recyclerView != null) { + final int itemCount = recyclerView.getAdapter().getItemCount(); + float proportion; + if (ViewUtil.getY(handle) == 0) + proportion = 0f; + else if (ViewUtil.getY(handle) + handle.getHeight() >= height - TRACK_SNAP_RANGE) + proportion = 1f; + else + proportion = y / (float) height; + final int targetPos = Util.clamp((int)(proportion * (float)itemCount), 0, itemCount - 1); + ((LinearLayoutManager) recyclerView.getLayoutManager()).scrollToPositionWithOffset(targetPos, 0); + final CharSequence bubbleText = ((FastScrollAdapter) recyclerView.getAdapter()).getBubbleText(targetPos); + if (bubble != null) + bubble.setText(bubbleText); + } + } + + private void setBubbleAndHandlePosition(float y) { + final int handleHeight = handle.getHeight(); + final int bubbleHeight = bubble.getHeight(); + ViewUtil.setY(handle, Util.clamp((int)(y - handleHeight / 2), 0, height - handleHeight)); + ViewUtil.setY(bubble, Util.clamp((int)(y - bubbleHeight), 0, height - bubbleHeight - handleHeight / 2)); + } + + @TargetApi(11) + private void showBubble() { + bubble.setVisibility(VISIBLE); + if (VERSION.SDK_INT >= 11) { + if (currentAnimator != null) currentAnimator.cancel(); + currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 0f, 1f).setDuration(BUBBLE_ANIMATION_DURATION); + currentAnimator.start(); + } + } + + @TargetApi(11) + private void hideBubble() { + if (VERSION.SDK_INT >= 11) { + if (currentAnimator != null) currentAnimator.cancel(); + currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 1f, 0f).setDuration(BUBBLE_ANIMATION_DURATION); + currentAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + bubble.setVisibility(INVISIBLE); + currentAnimator = null; + } + + @Override + public void onAnimationCancel(Animator animation) { + super.onAnimationCancel(animation); + bubble.setVisibility(INVISIBLE); + currentAnimator = null; + } + }); + currentAnimator.start(); + } else { + bubble.setVisibility(INVISIBLE); + } + } +} diff --git a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java index 87ae41d951..d882f02ac1 100644 --- a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java +++ b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java @@ -20,101 +20,174 @@ import android.content.Context; import android.content.res.TypedArray; import android.database.Cursor; import android.provider.ContactsContract; -import android.support.v4.widget.CursorAdapter; -import android.util.Log; +import android.support.annotation.DrawableRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.RecyclerView; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.ImageSpan; import android.view.LayoutInflater; import android.view.View; +import android.view.View.OnClickListener; import android.view.ViewGroup; import android.widget.TextView; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.RecyclerViewFastScroller.FastScrollAdapter; +import org.thoughtcrime.securesms.ContactSelectionListFragment.StickyHeaderAdapter; +import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.HeaderViewHolder; +import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.ViewHolder; +import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; import java.util.HashMap; import java.util.Map; -import se.emilsjolander.stickylistheaders.StickyListHeadersAdapter; - /** * List adapter to display all contacts and their related information * * @author Jake McGinty */ -public class ContactSelectionListAdapter extends CursorAdapter - implements StickyListHeadersAdapter +public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter + implements FastScrollAdapter, + StickyHeaderAdapter { private final static String TAG = ContactSelectionListAdapter.class.getSimpleName(); private final static int STYLE_ATTRIBUTES[] = new int[]{R.attr.contact_selection_push_user, R.attr.contact_selection_lay_user}; - private final boolean multiSelect; - private final LayoutInflater li; - private final TypedArray drawables; + private final boolean multiSelect; + private final LayoutInflater li; + private final TypedArray drawables; + private final ItemClickListener clickListener; private final HashMap selectedContacts = new HashMap<>(); - public ContactSelectionListAdapter(Context context, Cursor cursor, boolean multiSelect) { - super(context, cursor, 0); - this.li = LayoutInflater.from(context); - this.drawables = context.obtainStyledAttributes(STYLE_ATTRIBUTES); - this.multiSelect = multiSelect; + public static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(@NonNull final View itemView, + @Nullable final ItemClickListener clickListener) + { + super(itemView); + itemView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (clickListener != null) clickListener.onItemClick(getView()); + } + }); + } + + public ContactSelectionListItem getView() { + return (ContactSelectionListItem) itemView; + } + } + + public static class HeaderViewHolder extends RecyclerView.ViewHolder { + public HeaderViewHolder(View itemView) { + super(itemView); + } + } + + public ContactSelectionListAdapter(@NonNull Context context, + @Nullable Cursor cursor, + @Nullable ItemClickListener clickListener, + boolean multiSelect) + { + super(context, cursor); + this.li = LayoutInflater.from(context); + this.drawables = context.obtainStyledAttributes(STYLE_ATTRIBUTES); + this.multiSelect = multiSelect; + this.clickListener = clickListener; } @Override - public View newView(Context context, Cursor cursor, ViewGroup parent) { - return li.inflate(R.layout.contact_selection_list_item, parent, false); + public long getHeaderId(int i) { + return getHeaderString(i).hashCode(); } @Override - public void bindView(View view, Context context, Cursor cursor) { + public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) { + return new ViewHolder(li.inflate(R.layout.contact_selection_list_item, parent, false), clickListener); + } + + @Override + public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) { long id = cursor.getLong(cursor.getColumnIndexOrThrow(ContactsDatabase.ID_COLUMN)); int contactType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsDatabase.CONTACT_TYPE_COLUMN)); String name = cursor.getString(cursor.getColumnIndexOrThrow(ContactsDatabase.NAME_COLUMN)); String number = cursor.getString(cursor.getColumnIndexOrThrow(ContactsDatabase.NUMBER_COLUMN)); int numberType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsDatabase.NUMBER_TYPE_COLUMN)); String label = cursor.getString(cursor.getColumnIndexOrThrow(ContactsDatabase.LABEL_COLUMN)); - String labelText = ContactsContract.CommonDataKinds.Phone.getTypeLabel(context.getResources(), + String labelText = ContactsContract.CommonDataKinds.Phone.getTypeLabel(getContext().getResources(), numberType, label).toString(); int color = (contactType == ContactsDatabase.PUSH_TYPE) ? drawables.getColor(0, 0xa0000000) : - drawables.getColor(1, 0xff000000); + drawables.getColor(1, 0xff000000); - - ((ContactSelectionListItem)view).unbind(); - ((ContactSelectionListItem)view).set(id, contactType, name, number, labelText, color, multiSelect); - ((ContactSelectionListItem)view).setChecked(selectedContacts.containsKey(id)); + viewHolder.getView().unbind(); + viewHolder.getView().set(id, contactType, name, number, labelText, color, multiSelect); + viewHolder.getView().setChecked(selectedContacts.containsKey(id)); } @Override - public View getHeaderView(int i, View convertView, ViewGroup viewGroup) { - Cursor cursor = getCursor(); - - final TextView text; - if (convertView == null) { - text = (TextView)li.inflate(R.layout.contact_selection_list_header, viewGroup, false); - } else { - text = (TextView)convertView; - } - - cursor.moveToPosition(i); - - int contactType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsDatabase.CONTACT_TYPE_COLUMN)); - - if (contactType == ContactsDatabase.PUSH_TYPE) text.setText(R.string.contact_selection_list__header_signal_users); - else text.setText(R.string.contact_selection_list__header_other); - - return text; + public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent) { + return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.contact_selection_recyclerview_header, parent, false)); } @Override - public long getHeaderId(int i) { - Cursor cursor = getCursor(); - cursor.moveToPosition(i); + public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) { + ((TextView)viewHolder.itemView).setText(getSpannedHeaderString(position, R.drawable.ic_signal_grey_24dp)); + } - return cursor.getInt(cursor.getColumnIndexOrThrow(ContactsDatabase.CONTACT_TYPE_COLUMN)); + @Override + public CharSequence getBubbleText(int position) { + return getSpannedHeaderString(position, R.drawable.ic_signal_white_48dp); } public Map getSelectedContacts() { return selectedContacts; } + + private CharSequence getSpannedHeaderString(int position, @DrawableRes int drawable) { + Cursor cursor = getCursorAtPositionOrThrow(position); + + if (cursor.getInt(cursor.getColumnIndexOrThrow(ContactsDatabase.CONTACT_TYPE_COLUMN)) == ContactsDatabase.PUSH_TYPE) { + SpannableString spannable = new SpannableString(" "); + spannable.setSpan(new ImageSpan(getContext(), drawable, ImageSpan.ALIGN_BOTTOM), 0, spannable.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } else { + return getHeaderString(position); + } + } + + private String getHeaderString(int position) { + Cursor cursor = getCursorAtPositionOrThrow(position); + + if (cursor.getInt(cursor.getColumnIndexOrThrow(ContactsDatabase.CONTACT_TYPE_COLUMN)) == ContactsDatabase.PUSH_TYPE) { + return getContext().getString(R.string.app_name); + } else { + String letter = cursor.getString(cursor.getColumnIndexOrThrow(ContactsDatabase.NAME_COLUMN)) + .trim() + .substring(0,1) + .toUpperCase(); + if (Character.isLetterOrDigit(letter.codePointAt(0))) { + return letter; + } else { + return "#"; + } + } + } + + private Cursor getCursorAtPositionOrThrow(int position) { + Cursor cursor = getCursor(); + if (cursor == null) { + throw new IllegalStateException("Cursor should not be null here."); + } + if (!cursor.moveToPosition(position)); + return cursor; + } + + public interface ItemClickListener { + void onItemClick(ContactSelectionListItem item); + } } diff --git a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java index c7b16a6582..70a1870893 100644 --- a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java +++ b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java @@ -5,7 +5,7 @@ import android.text.TextUtils; import android.util.AttributeSet; import android.view.View; import android.widget.CheckBox; -import android.widget.RelativeLayout; +import android.widget.LinearLayout; import android.widget.TextView; import org.thoughtcrime.securesms.R; @@ -14,7 +14,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientFactory; import org.thoughtcrime.securesms.recipients.Recipients; -public class ContactSelectionListItem extends RelativeLayout implements Recipients.RecipientsModifiedListener { +public class ContactSelectionListItem extends LinearLayout implements Recipients.RecipientsModifiedListener { private AvatarImageView contactPhotoImage; private TextView numberView; @@ -34,10 +34,6 @@ public class ContactSelectionListItem extends RelativeLayout implements Recipien super(context, attrs); } - public ContactSelectionListItem(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - @Override protected void onFinishInflate() { super.onFinishInflate(); diff --git a/src/org/thoughtcrime/securesms/util/ViewUtil.java b/src/org/thoughtcrime/securesms/util/ViewUtil.java index 9cd4af625e..fdfbfa943d 100644 --- a/src/org/thoughtcrime/securesms/util/ViewUtil.java +++ b/src/org/thoughtcrime/securesms/util/ViewUtil.java @@ -24,6 +24,7 @@ import android.support.annotation.IdRes; import android.support.annotation.LayoutRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.v4.view.ViewCompat; import android.support.v4.view.animation.FastOutSlowInInterpolator; import android.text.TextUtils; import android.text.TextUtils.TruncateAt; @@ -33,6 +34,7 @@ import android.view.ViewGroup; import android.view.ViewStub; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; +import android.widget.LinearLayout.LayoutParams; import android.widget.TextView; public class ViewUtil { @@ -45,6 +47,42 @@ public class ViewUtil { } } + public static void setY(final @NonNull View v, final int y) { + if (VERSION.SDK_INT >= 11) { + ViewCompat.setY(v, y); + } else { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams)v.getLayoutParams(); + params.topMargin = y; + v.setLayoutParams(params); + } + } + + public static float getY(final @NonNull View v) { + if (VERSION.SDK_INT >= 11) { + return ViewCompat.getY(v); + } else { + return ((ViewGroup.MarginLayoutParams)v.getLayoutParams()).topMargin; + } + } + + public static void setX(final @NonNull View v, final int x) { + if (VERSION.SDK_INT >= 11) { + ViewCompat.setX(v, x); + } else { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams)v.getLayoutParams(); + params.leftMargin = x; + v.setLayoutParams(params); + } + } + + public static float getX(final @NonNull View v) { + if (VERSION.SDK_INT >= 11) { + return ViewCompat.getX(v); + } else { + return ((LayoutParams)v.getLayoutParams()).leftMargin; + } + } + public static void swapChildInPlace(ViewGroup parent, View toRemove, View toAdd, int defaultIndex) { int childIndex = parent.indexOfChild(toRemove); if (childIndex > -1) parent.removeView(toRemove);