mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-25 03:11:10 +01:00
Update chat colors.
This commit is contained in:
committed by
Greyson Parrelli
parent
36fe150678
commit
bcc5d485ab
@@ -0,0 +1,56 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Path
|
||||
import android.graphics.Region
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
|
||||
/**
|
||||
* Drawable which clips out the given projection
|
||||
*/
|
||||
class ClipProjectionDrawable(wrapped: Drawable) : LayerDrawable(arrayOf(wrapped)) {
|
||||
|
||||
constructor() : this(ColorDrawable(Color.TRANSPARENT))
|
||||
|
||||
init {
|
||||
setId(0, 0)
|
||||
}
|
||||
|
||||
private val clipPath = Path()
|
||||
private var projections: List<Projection> = listOf()
|
||||
|
||||
fun setWrappedDrawable(drawable: Drawable) {
|
||||
setDrawableByLayerId(0, drawable)
|
||||
}
|
||||
|
||||
fun setProjections(projections: Set<Projection>) {
|
||||
this.projections = projections.toList()
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
fun clearProjections() {
|
||||
this.projections = listOf()
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
if (projections.isNotEmpty()) {
|
||||
canvas.save()
|
||||
clipPath.rewind()
|
||||
|
||||
projections.forEach {
|
||||
it.applyToPath(clipPath)
|
||||
}
|
||||
|
||||
canvas.clipPath(clipPath, Region.Op.DIFFERENCE)
|
||||
super.draw(canvas)
|
||||
canvas.restore()
|
||||
} else {
|
||||
super.draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1097,7 +1097,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
|
||||
break;
|
||||
case GIF:
|
||||
AttachmentManager.selectGif(this, PICK_GIF, !isSecureText, recipient.get().getColor().toConversationColor(this));
|
||||
AttachmentManager.selectGif(this, PICK_GIF, !isSecureText, recipient.get().getChatColors().asSingleColor());
|
||||
break;
|
||||
case FILE:
|
||||
AttachmentManager.selectDocument(this, PICK_DOCUMENT);
|
||||
@@ -1252,7 +1252,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
GlideApp.with(this)
|
||||
.asBitmap()
|
||||
.load(recipient.getContactPhoto())
|
||||
.error(recipient.getFallbackContactPhoto().asDrawable(this, recipient.getColor().toAvatarColor(this), false))
|
||||
.error(recipient.getFallbackContactPhoto().asDrawable(this, recipient.getChatColors(), false))
|
||||
.into(new CustomTarget<Bitmap>() {
|
||||
@Override
|
||||
public void onLoadFailed(@Nullable Drawable errorDrawable) {
|
||||
@@ -3506,7 +3506,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
messageRecord.getDateSent(),
|
||||
author,
|
||||
body,
|
||||
slideDeck);
|
||||
slideDeck,
|
||||
fragment.getColorizer());
|
||||
|
||||
} else if (messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getLinkPreviews().isEmpty()) {
|
||||
LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0);
|
||||
@@ -3520,7 +3521,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
messageRecord.getDateSent(),
|
||||
author,
|
||||
conversationMessage.getDisplayBody(this),
|
||||
slideDeck);
|
||||
slideDeck,
|
||||
fragment.getColorizer());
|
||||
} else {
|
||||
SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck();
|
||||
|
||||
@@ -3534,7 +3536,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
messageRecord.getDateSent(),
|
||||
author,
|
||||
conversationMessage.getDisplayBody(this),
|
||||
slideDeck);
|
||||
slideDeck,
|
||||
fragment.getColorizer());
|
||||
}
|
||||
|
||||
inputPanel.clickOnComposeInput();
|
||||
|
||||
@@ -43,11 +43,12 @@ import org.signal.core.util.logging.Log;
|
||||
import org.signal.paging.PagingController;
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.MaskView;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -123,13 +124,15 @@ public class ConversationAdapter
|
||||
private boolean hasWallpaper;
|
||||
private boolean isMessageRequestAccepted;
|
||||
private ConversationMessage inlineContent;
|
||||
private Colorizer colorizer;
|
||||
|
||||
ConversationAdapter(@NonNull LifecycleOwner lifecycleOwner,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@Nullable ItemClickListener clickListener,
|
||||
@NonNull Recipient recipient,
|
||||
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory)
|
||||
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
|
||||
@NonNull Colorizer colorizer)
|
||||
{
|
||||
super(new DiffUtil.ItemCallback<ConversationMessage>() {
|
||||
@Override
|
||||
@@ -157,6 +160,7 @@ public class ConversationAdapter
|
||||
this.hasWallpaper = recipient.hasWallpaper();
|
||||
this.isMessageRequestAccepted = true;
|
||||
this.attachmentMediaSourceFactory = attachmentMediaSourceFactory;
|
||||
this.colorizer = colorizer;
|
||||
|
||||
setHasStableIds(true);
|
||||
}
|
||||
@@ -271,7 +275,8 @@ public class ConversationAdapter
|
||||
hasWallpaper,
|
||||
isMessageRequestAccepted,
|
||||
attachmentMediaSourceFactory,
|
||||
conversationMessage == inlineContent);
|
||||
conversationMessage == inlineContent,
|
||||
colorizer);
|
||||
|
||||
if (conversationMessage == recordToPulse) {
|
||||
recordToPulse = null;
|
||||
@@ -635,7 +640,7 @@ public class ConversationAdapter
|
||||
}
|
||||
}
|
||||
|
||||
final static class ConversationViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable {
|
||||
final static class ConversationViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable, Colorizable {
|
||||
public ConversationViewHolder(final @NonNull View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
@@ -665,7 +670,7 @@ public class ConversationAdapter
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public @Override GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
|
||||
public @Override Projection getProjection(@NonNull ViewGroup recyclerView) {
|
||||
return getBindable().getProjection(recyclerView);
|
||||
}
|
||||
|
||||
@@ -673,6 +678,11 @@ public class ConversationAdapter
|
||||
public boolean canPlayContent() {
|
||||
return getBindable().canPlayContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull List<Projection> getColorizerProjections() {
|
||||
return getBindable().getColorizerProjections();
|
||||
}
|
||||
}
|
||||
|
||||
static class StickyHeaderViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
@@ -89,6 +89,8 @@ import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerView;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
@@ -100,9 +102,10 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||
@@ -183,39 +186,41 @@ public class ConversationFragment extends LoggingFragment {
|
||||
|
||||
private ConversationFragmentListener listener;
|
||||
|
||||
private LiveRecipient recipient;
|
||||
private long threadId;
|
||||
private boolean isReacting;
|
||||
private ActionMode actionMode;
|
||||
private Locale locale;
|
||||
private FrameLayout videoContainer;
|
||||
private RecyclerView list;
|
||||
private RecyclerView.ItemDecoration lastSeenDecoration;
|
||||
private RecyclerView.ItemDecoration inlineDateDecoration;
|
||||
private ViewSwitcher topLoadMoreView;
|
||||
private ViewSwitcher bottomLoadMoreView;
|
||||
private ConversationTypingView typingView;
|
||||
private View composeDivider;
|
||||
private ConversationScrollToView scrollToBottomButton;
|
||||
private ConversationScrollToView scrollToMentionButton;
|
||||
private TextView scrollDateHeader;
|
||||
private ConversationBannerView conversationBanner;
|
||||
private MessageRequestViewModel messageRequestViewModel;
|
||||
private MessageCountsViewModel messageCountsViewModel;
|
||||
private ConversationViewModel conversationViewModel;
|
||||
private SnapToTopDataObserver snapToTopDataObserver;
|
||||
private MarkReadHelper markReadHelper;
|
||||
private Animation scrollButtonInAnimation;
|
||||
private Animation mentionButtonInAnimation;
|
||||
private Animation scrollButtonOutAnimation;
|
||||
private Animation mentionButtonOutAnimation;
|
||||
private OnScrollListener conversationScrollListener;
|
||||
private int pulsePosition = -1;
|
||||
private VoiceNoteMediaController voiceNoteMediaController;
|
||||
private View toolbarShadow;
|
||||
private Stopwatch startupStopwatch;
|
||||
private LiveRecipient recipient;
|
||||
private long threadId;
|
||||
private boolean isReacting;
|
||||
private ActionMode actionMode;
|
||||
private Locale locale;
|
||||
private FrameLayout videoContainer;
|
||||
private RecyclerView list;
|
||||
private RecyclerView.ItemDecoration lastSeenDecoration;
|
||||
private RecyclerView.ItemDecoration inlineDateDecoration;
|
||||
private ViewSwitcher topLoadMoreView;
|
||||
private ViewSwitcher bottomLoadMoreView;
|
||||
private ConversationTypingView typingView;
|
||||
private View composeDivider;
|
||||
private ConversationScrollToView scrollToBottomButton;
|
||||
private ConversationScrollToView scrollToMentionButton;
|
||||
private TextView scrollDateHeader;
|
||||
private ConversationBannerView conversationBanner;
|
||||
private MessageRequestViewModel messageRequestViewModel;
|
||||
private MessageCountsViewModel messageCountsViewModel;
|
||||
private ConversationViewModel conversationViewModel;
|
||||
private SnapToTopDataObserver snapToTopDataObserver;
|
||||
private MarkReadHelper markReadHelper;
|
||||
private Animation scrollButtonInAnimation;
|
||||
private Animation mentionButtonInAnimation;
|
||||
private Animation scrollButtonOutAnimation;
|
||||
private Animation mentionButtonOutAnimation;
|
||||
private OnScrollListener conversationScrollListener;
|
||||
private int pulsePosition = -1;
|
||||
private VoiceNoteMediaController voiceNoteMediaController;
|
||||
private View toolbarShadow;
|
||||
private ColorizerView colorizerView;
|
||||
private Stopwatch startupStopwatch;
|
||||
|
||||
private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler;
|
||||
private Colorizer colorizer;
|
||||
|
||||
public static void prepare(@NonNull Context context) {
|
||||
FrameLayout parent = new FrameLayout(context);
|
||||
@@ -247,6 +252,10 @@ public class ConversationFragment extends LoggingFragment {
|
||||
scrollToMentionButton = view.findViewById(R.id.scroll_to_mention);
|
||||
scrollDateHeader = view.findViewById(R.id.scroll_date_header);
|
||||
toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow);
|
||||
colorizerView = view.findViewById(R.id.conversation_colorizer_view);
|
||||
|
||||
ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
|
||||
colorizerView.setBackground(args.getChatColors().getChatBubbleMask());
|
||||
|
||||
final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
|
||||
list.setHasFixedSize(false);
|
||||
@@ -272,7 +281,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
conversationMessage.getMessageRecord(),
|
||||
messageRequestViewModel.shouldShowMessageRequest()),
|
||||
this::handleReplyMessage,
|
||||
giphyMp4ProjectionRecycler
|
||||
this::onViewHolderPositionTranslated
|
||||
).attachToRecyclerView(list);
|
||||
|
||||
setupListLayoutListeners();
|
||||
@@ -310,6 +319,19 @@ public class ConversationFragment extends LoggingFragment {
|
||||
|
||||
updateToolbarDependentMargins();
|
||||
|
||||
colorizer = new Colorizer(colorizerView);
|
||||
colorizer.attachToRecyclerView(list);
|
||||
|
||||
conversationViewModel.getChatColors().observe(getViewLifecycleOwner(), chatColors -> colorizer.onChatColorsChanged(chatColors));
|
||||
conversationViewModel.getNameColorsMap().observe(getViewLifecycleOwner(), nameColorsMap -> {
|
||||
colorizer.onNameColorsChanged(nameColorsMap);
|
||||
|
||||
ConversationAdapter adapter = getListAdapter();
|
||||
if (adapter != null) {
|
||||
adapter.notifyDataSetChanged();
|
||||
}
|
||||
});
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -352,10 +374,12 @@ public class ConversationFragment extends LoggingFragment {
|
||||
private void setListVerticalTranslation() {
|
||||
if (list.canScrollVertically(1) || list.canScrollVertically(-1) || list.getChildCount() == 0) {
|
||||
list.setTranslationY(0);
|
||||
colorizerView.setTranslationY(0);
|
||||
list.setOverScrollMode(RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS);
|
||||
} else {
|
||||
int chTop = list.getChildAt(list.getChildCount() - 1).getTop();
|
||||
list.setTranslationY(Math.min(0, -chTop));
|
||||
colorizerView.setTranslationY(Math.min(0, -chTop));
|
||||
list.setOverScrollMode(RecyclerView.OVER_SCROLL_NEVER);
|
||||
}
|
||||
|
||||
@@ -467,6 +491,16 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void onViewHolderPositionTranslated(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
if (viewHolder instanceof GiphyMp4Playable) {
|
||||
giphyMp4ProjectionRecycler.updateDisplay(recyclerView, (GiphyMp4Playable) viewHolder);
|
||||
}
|
||||
|
||||
if (colorizer != null) {
|
||||
colorizer.applyClipPathsToMaskedGradient(recyclerView);
|
||||
}
|
||||
}
|
||||
|
||||
private int getStartPosition() {
|
||||
return conversationViewModel.getArgs().getStartingPosition();
|
||||
}
|
||||
@@ -616,7 +650,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
Log.d(TAG, "Initializing adapter for " + recipient.getId());
|
||||
ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), new AttachmentMediaSourceFactory(requireContext()));
|
||||
ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), new AttachmentMediaSourceFactory(requireContext()), colorizer);
|
||||
adapter.setPagingController(conversationViewModel.getPagingController());
|
||||
list.setAdapter(adapter);
|
||||
setInlineDateDecoration(adapter);
|
||||
@@ -1124,6 +1158,10 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull Colorizer getColorizer() {
|
||||
return Objects.requireNonNull(colorizer);
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
public void jumpToMessage(@NonNull RecipientId author, long timestamp, @Nullable Runnable onMessageNotFound) {
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -67,8 +68,8 @@ public class ConversationIntents {
|
||||
private final StickerLocator stickerLocator;
|
||||
private final boolean isBorderless;
|
||||
private final int distributionType;
|
||||
private final int startingPosition;
|
||||
private final boolean firstTimeInSelfCreatedGroup;
|
||||
private final int startingPosition;
|
||||
private final boolean firstTimeInSelfCreatedGroup;
|
||||
|
||||
static Args from(@NonNull Intent intent) {
|
||||
if (isBubbleIntent(intent)) {
|
||||
@@ -155,6 +156,10 @@ public class ConversationIntents {
|
||||
// TODO [greyson][wallpaper] Is it worth it to do this beforehand?
|
||||
return Recipient.resolved(recipientId).getWallpaper();
|
||||
}
|
||||
|
||||
public @NonNull ChatColors getChatColors() {
|
||||
return Recipient.resolved(recipientId).getChatColors();
|
||||
}
|
||||
}
|
||||
|
||||
public final static class Builder {
|
||||
|
||||
@@ -55,8 +55,8 @@ import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
|
||||
@@ -72,7 +72,6 @@ import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.components.BorderlessImageView;
|
||||
import org.thoughtcrime.securesms.components.ConversationItemFooter;
|
||||
import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
|
||||
import org.thoughtcrime.securesms.components.CornerMask;
|
||||
import org.thoughtcrime.securesms.components.DocumentView;
|
||||
import org.thoughtcrime.securesms.components.LinkPreviewView;
|
||||
import org.thoughtcrime.securesms.components.Outliner;
|
||||
@@ -81,6 +80,7 @@ import org.thoughtcrime.securesms.components.SharedContactView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
@@ -92,7 +92,6 @@ import org.thoughtcrime.securesms.database.model.Quote;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.MmsDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.MmsSendJob;
|
||||
@@ -121,9 +120,9 @@ import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan;
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.SearchUtil;
|
||||
import org.thoughtcrime.securesms.util.StringUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UrlClickHandler;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.VibrateUtil;
|
||||
@@ -135,6 +134,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@@ -160,13 +160,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
private static final Rect SWIPE_RECT = new Rect();
|
||||
|
||||
private ConversationMessage conversationMessage;
|
||||
private MessageRecord messageRecord;
|
||||
private Locale locale;
|
||||
private boolean groupThread;
|
||||
private LiveRecipient recipient;
|
||||
private GlideRequests glideRequests;
|
||||
private ValueAnimator pulseOutlinerAlphaAnimator;
|
||||
private ClipProjectionDrawable backgroundDrawable;
|
||||
private ConversationMessage conversationMessage;
|
||||
private MessageRecord messageRecord;
|
||||
private Locale locale;
|
||||
private boolean groupThread;
|
||||
private LiveRecipient recipient;
|
||||
private GlideRequests glideRequests;
|
||||
private ValueAnimator pulseOutlinerAlphaAnimator;
|
||||
|
||||
protected ConversationItemBodyBubble bodyBubble;
|
||||
protected View reply;
|
||||
@@ -212,8 +213,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
private final Context context;
|
||||
|
||||
private MediaSource mediaSource;
|
||||
private boolean canPlayContent;
|
||||
private MediaSource mediaSource;
|
||||
private boolean canPlayContent;
|
||||
private Projection.Corners bodyBubbleCorners;
|
||||
private Colorizer colorizer;
|
||||
private boolean hasWallpaper;
|
||||
|
||||
public ConversationItem(Context context) {
|
||||
this(context, null);
|
||||
@@ -235,6 +239,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
initializeAttributes();
|
||||
|
||||
this.backgroundDrawable = new ClipProjectionDrawable(Objects.requireNonNull(ContextCompat.getDrawable(getContext(),
|
||||
R.drawable.conversation_item_background)));
|
||||
this.bodyText = findViewById(R.id.conversation_item_body);
|
||||
this.footer = findViewById(R.id.conversation_item_footer);
|
||||
this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer);
|
||||
@@ -276,7 +282,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
boolean hasWallpaper,
|
||||
boolean isMessageRequestAccepted,
|
||||
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
|
||||
boolean allowedToPlayInline)
|
||||
boolean allowedToPlayInline,
|
||||
@NonNull Colorizer colorizer)
|
||||
{
|
||||
if (this.recipient != null) this.recipient.removeForeverObserver(this);
|
||||
if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this);
|
||||
@@ -293,6 +300,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
this.recipient = messageRecord.getIndividualRecipient().live();
|
||||
this.canPlayContent = false;
|
||||
this.mediaSource = null;
|
||||
this.colorizer = colorizer;
|
||||
|
||||
this.recipient.observeForever(this);
|
||||
this.conversationRecipient.observeForever(this);
|
||||
@@ -301,14 +309,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
|
||||
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted, attachmentMediaSourceFactory, allowedToPlayInline);
|
||||
setBodyText(messageRecord, searchQuery, isMessageRequestAccepted);
|
||||
setBubbleState(messageRecord, hasWallpaper);
|
||||
setBubbleState(messageRecord, messageRecord.getRecipient(), hasWallpaper, colorizer);
|
||||
setInteractionState(conversationMessage, pulse);
|
||||
setStatusIcons(messageRecord, hasWallpaper);
|
||||
setContactPhoto(recipient.get());
|
||||
setGroupMessageStatus(messageRecord, recipient.get());
|
||||
setGroupAuthorColor(messageRecord, hasWallpaper);
|
||||
setGroupAuthorColor(messageRecord, hasWallpaper, colorizer);
|
||||
setAuthor(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper);
|
||||
setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
|
||||
setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, colorizer);
|
||||
setMessageSpacing(context, messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
|
||||
setReactions(messageRecord);
|
||||
setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper);
|
||||
@@ -390,7 +398,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
@Override
|
||||
public void onRecipientChanged(@NonNull Recipient modified) {
|
||||
setBubbleState(messageRecord, modified.hasWallpaper());
|
||||
setBubbleState(messageRecord, modified, modified.hasWallpaper(), colorizer);
|
||||
if (recipient.getId().equals(modified.getId())) {
|
||||
setContactPhoto(modified);
|
||||
setGroupMessageStatus(messageRecord, modified);
|
||||
@@ -438,11 +446,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
/// MessageRecord Attribute Parsers
|
||||
|
||||
private void setBubbleState(MessageRecord messageRecord, boolean hasWallpaper) {
|
||||
private void setBubbleState(MessageRecord messageRecord, @NonNull Recipient recipient, boolean hasWallpaper, @NonNull Colorizer colorizer) {
|
||||
this.hasWallpaper = hasWallpaper;
|
||||
|
||||
bodyText.setTextColor(ContextCompat.getColor(getContext(), R.color.signal_text_primary));
|
||||
bodyText.setLinkTextColor(ContextCompat.getColor(getContext(), R.color.signal_text_primary));
|
||||
|
||||
if (messageRecord.isOutgoing() && !messageRecord.isRemoteDelete()) {
|
||||
bodyBubble.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.MULTIPLY);
|
||||
footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
|
||||
footer.setIconColor(ContextCompat.getColor(context, R.color.signal_icon_tint_secondary));
|
||||
bodyBubble.getBackground().setColorFilter(recipient.getChatColors().getChatBubbleColorFilter());
|
||||
bodyText.setTextColor(colorizer.getOutgoingBodyTextColor(context));
|
||||
bodyText.setLinkTextColor(colorizer.getOutgoingBodyTextColor(context));
|
||||
footer.setTextColor(colorizer.getOutgoingFooterTextColor(context));
|
||||
footer.setIconColor(colorizer.getOutgoingFooterIconColor(context));
|
||||
footer.setOnlyShowSendingStatus(false, messageRecord);
|
||||
} else if (messageRecord.isRemoteDelete() || (isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord))) {
|
||||
if (hasWallpaper) {
|
||||
@@ -454,9 +469,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
|
||||
footer.setOnlyShowSendingStatus(messageRecord.isRemoteDelete(), messageRecord);
|
||||
} else {
|
||||
bodyBubble.getBackground().setColorFilter(messageRecord.getRecipient().getColor().toConversationColor(context), PorterDuff.Mode.MULTIPLY);
|
||||
footer.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
|
||||
footer.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
|
||||
bodyBubble.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.SRC_IN);
|
||||
footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
|
||||
footer.setIconColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
|
||||
footer.setOnlyShowSendingStatus(false, messageRecord);
|
||||
}
|
||||
|
||||
@@ -490,7 +505,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
private void setAudioViewTint(MessageRecord messageRecord) {
|
||||
if (hasAudio(messageRecord)) {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
if (!messageRecord.isOutgoing()) {
|
||||
if (DynamicTheme.isDarkTheme(context)) {
|
||||
audioViewStub.get().setTint(Color.WHITE);
|
||||
} else {
|
||||
@@ -504,7 +519,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
private void setInteractionState(ConversationMessage conversationMessage, boolean pulseMention) {
|
||||
if (batchSelected.contains(conversationMessage)) {
|
||||
setBackgroundResource(R.drawable.conversation_item_background);
|
||||
setBackground(backgroundDrawable);
|
||||
setSelected(true);
|
||||
} else if (pulseMention) {
|
||||
setBackground(null);
|
||||
@@ -683,9 +698,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
|
||||
if (messageRecord.isOutgoing()) {
|
||||
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20));
|
||||
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_25));
|
||||
} else {
|
||||
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_40));
|
||||
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20));
|
||||
}
|
||||
|
||||
bodyText.setText(StringUtil.trim(styledText));
|
||||
@@ -704,6 +719,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
{
|
||||
boolean showControls = !messageRecord.isFailed();
|
||||
|
||||
bodyBubble.setQuoteViewProjection(null);
|
||||
bodyBubble.setVideoPlayerProjection(null);
|
||||
updateBackgroundDrawableProjections();
|
||||
|
||||
if (eventListener != null && audioViewStub.resolved()) {
|
||||
Log.d(TAG, "setMediaAttributes: unregistering voice note callbacks for audio slide " + audioViewStub.get().getAudioSlideUri());
|
||||
eventListener.onUnregisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver());
|
||||
@@ -875,8 +894,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
mediaThumbnailStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
mediaThumbnailStub.get().setOnClickListener(passthroughClickListener);
|
||||
mediaThumbnailStub.get().showShade(TextUtils.isEmpty(messageRecord.getDisplayBody(getContext())) && !hasExtraText(messageRecord));
|
||||
mediaThumbnailStub.get().setConversationColor(messageRecord.isOutgoing() ? getDefaultBubbleColor(hasWallpaper)
|
||||
: messageRecord.getRecipient().getColor().toConversationColor(context));
|
||||
|
||||
if (!messageRecord.isOutgoing()) {
|
||||
mediaThumbnailStub.get().setConversationColor(getDefaultBubbleColor(hasWallpaper));
|
||||
} else {
|
||||
mediaThumbnailStub.get().setConversationColor(Color.TRANSPARENT);
|
||||
}
|
||||
|
||||
mediaThumbnailStub.get().setBorderless(false);
|
||||
|
||||
setThumbnailCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
|
||||
@@ -1068,14 +1092,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
|
||||
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread, @NonNull Colorizer colorizer) {
|
||||
if (current.isMms() && !current.isMmsNotification() && ((MediaMmsMessageRecord)current).getQuote() != null) {
|
||||
if (quoteView == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
Quote quote = ((MediaMmsMessageRecord)current).getQuote();
|
||||
//noinspection ConstantConditions
|
||||
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment());
|
||||
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment(), colorizer);
|
||||
quoteView.setVisibility(View.VISIBLE);
|
||||
quoteView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
|
||||
@@ -1177,11 +1201,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
if (hasWallpaper && hasNoBubble((messageRecord))) {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, getDefaultBubbleColor(hasWallpaper));
|
||||
activeFooter.disableBubbleBackground();
|
||||
activeFooter.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_sent_text_secondary_color));
|
||||
activeFooter.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_sent_text_secondary_color));
|
||||
} else {
|
||||
activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, messageRecord.getRecipient().getColor().toConversationColor(context));
|
||||
activeFooter.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
|
||||
activeFooter.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
|
||||
activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, getDefaultBubbleColor(hasWallpaper));
|
||||
}
|
||||
} else if (hasNoBubble(messageRecord)){
|
||||
activeFooter.disableBubbleBackground();
|
||||
@@ -1228,17 +1252,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private void setGroupAuthorColor(@NonNull MessageRecord messageRecord, boolean hasWallpaper) {
|
||||
private void setGroupAuthorColor(@NonNull MessageRecord messageRecord, boolean hasWallpaper, @NonNull Colorizer colorizer) {
|
||||
if (groupSender != null) {
|
||||
int stickerAuthorColor = ContextCompat.getColor(context, R.color.signal_text_primary);
|
||||
|
||||
if (shouldDrawBodyBubbleOutline(messageRecord, false)) {
|
||||
groupSender.setTextColor(stickerAuthorColor);
|
||||
} else if (!hasWallpaper && hasNoBubble(messageRecord)) {
|
||||
groupSender.setTextColor(stickerAuthorColor);
|
||||
} else {
|
||||
groupSender.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_primary_color));
|
||||
}
|
||||
groupSender.setTextColor(colorizer.getIncomingGroupSenderColor(getContext(), messageRecord.getIndividualRecipient()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1254,7 +1270,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
if (hasWallpaper && hasNoBubble(current)) {
|
||||
groupSenderHolder.setBackgroundResource(R.drawable.wallpaper_bubble_background_tintable_11);
|
||||
groupSenderHolder.getBackground().setColorFilter(messageRecord.getRecipient().getColor().toConversationColor(context), PorterDuff.Mode.MULTIPLY);
|
||||
groupSenderHolder.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.MULTIPLY);
|
||||
} else {
|
||||
groupSenderHolder.setBackground(null);
|
||||
}
|
||||
@@ -1297,40 +1313,48 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
background = R.drawable.message_bubble_background_sent_alone;
|
||||
outliner.setRadius(bigRadius);
|
||||
pulseOutliner.setRadius(bigRadius);
|
||||
bodyBubbleCorners = new Projection.Corners(bigRadius);
|
||||
} else {
|
||||
background = R.drawable.message_bubble_background_received_alone;
|
||||
outliner.setRadius(bigRadius);
|
||||
pulseOutliner.setRadius(bigRadius);
|
||||
bodyBubbleCorners = null;
|
||||
}
|
||||
} else if (isStartOfMessageCluster(current, previous, isGroupThread)) {
|
||||
if (current.isOutgoing()) {
|
||||
background = R.drawable.message_bubble_background_sent_start;
|
||||
setOutlinerRadii(outliner, bigRadius, bigRadius, smallRadius, bigRadius);
|
||||
setOutlinerRadii(pulseOutliner, bigRadius, bigRadius, smallRadius, bigRadius);
|
||||
bodyBubbleCorners = new Projection.Corners(bigRadius, bigRadius, smallRadius, bigRadius);
|
||||
} else {
|
||||
background = R.drawable.message_bubble_background_received_start;
|
||||
setOutlinerRadii(outliner, bigRadius, bigRadius, bigRadius, smallRadius);
|
||||
setOutlinerRadii(pulseOutliner, bigRadius, bigRadius, bigRadius, smallRadius);
|
||||
bodyBubbleCorners = null;
|
||||
}
|
||||
} else if (isEndOfMessageCluster(current, next, isGroupThread)) {
|
||||
if (current.isOutgoing()) {
|
||||
background = R.drawable.message_bubble_background_sent_end;
|
||||
setOutlinerRadii(outliner, bigRadius, smallRadius, bigRadius, bigRadius);
|
||||
setOutlinerRadii(pulseOutliner, bigRadius, smallRadius, bigRadius, bigRadius);
|
||||
bodyBubbleCorners = new Projection.Corners(bigRadius, smallRadius, bigRadius, bigRadius);
|
||||
} else {
|
||||
background = R.drawable.message_bubble_background_received_end;
|
||||
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, bigRadius);
|
||||
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, bigRadius);
|
||||
bodyBubbleCorners = null;
|
||||
}
|
||||
} else {
|
||||
if (current.isOutgoing()) {
|
||||
background = R.drawable.message_bubble_background_sent_middle;
|
||||
setOutlinerRadii(outliner, bigRadius, smallRadius, smallRadius, bigRadius);
|
||||
setOutlinerRadii(pulseOutliner, bigRadius, smallRadius, smallRadius, bigRadius);
|
||||
bodyBubbleCorners = new Projection.Corners(bigRadius, smallRadius, smallRadius, bigRadius);
|
||||
} else {
|
||||
background = R.drawable.message_bubble_background_received_middle;
|
||||
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, smallRadius);
|
||||
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, smallRadius);
|
||||
bodyBubbleCorners = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1440,7 +1464,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
public void showProjectionArea() {
|
||||
if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) {
|
||||
mediaThumbnailStub.get().showThumbnailView();
|
||||
bodyBubble.setMask(null);
|
||||
bodyBubble.setVideoPlayerProjection(null);
|
||||
updateBackgroundDrawableProjections();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1449,7 +1474,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) {
|
||||
mediaThumbnailStub.get().hideThumbnailView();
|
||||
mediaThumbnailStub.get().getDrawingRect(thumbnailMaskingRect);
|
||||
bodyBubble.setMask(thumbnailMaskingRect);
|
||||
bodyBubble.setVideoPlayerProjection(Projection.relativeToViewWithCommonRoot(mediaThumbnailStub.get(), bodyBubble, null));
|
||||
updateBackgroundDrawableProjections();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1475,9 +1501,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
|
||||
return GiphyMp4Projection.forView(recyclerView, mediaThumbnailStub.get(), mediaThumbnailStub.get().getCornerMask())
|
||||
.translateX(bodyBubble.getTranslationX());
|
||||
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) {
|
||||
return Projection.relativeToParent(recyclerView, mediaThumbnailStub.get(), mediaThumbnailStub.get().getCorners())
|
||||
.translateX(bodyBubble.getTranslationX());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1494,8 +1520,55 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
return rect;
|
||||
}
|
||||
|
||||
public @NonNull CornerMask getThumbnailCornerMask(@NonNull View view) {
|
||||
return new CornerMask(view, mediaThumbnailStub.get().getCornerMask());
|
||||
public @NonNull Projection.Corners getThumbnailCorners() {
|
||||
return mediaThumbnailStub.get().getCorners();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull List<Projection> getColorizerProjections() {
|
||||
List<Projection> projections = new LinkedList<>();
|
||||
|
||||
if (messageRecord.isOutgoing() &&
|
||||
!hasNoBubble(messageRecord) &&
|
||||
bodyBubbleCorners != null)
|
||||
{
|
||||
projections.add(Projection.relativeToViewRoot(bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX()));
|
||||
}
|
||||
|
||||
if (messageRecord.isOutgoing() &&
|
||||
hasNoBubble(messageRecord) &&
|
||||
hasWallpaper)
|
||||
{
|
||||
Projection footerProjection = getActiveFooter(messageRecord).getProjection();
|
||||
if (footerProjection != null) {
|
||||
projections.add(footerProjection.translateX(bodyBubble.getTranslationX()));
|
||||
}
|
||||
}
|
||||
|
||||
if (!messageRecord.isOutgoing() &&
|
||||
hasQuote(messageRecord) &&
|
||||
quoteView != null)
|
||||
{
|
||||
bodyBubble.setQuoteViewProjection(quoteView.getProjection(bodyBubble));
|
||||
projections.add(quoteView.getProjection((ViewGroup) getRootView()).translateX(bodyBubble.getTranslationX()));
|
||||
}
|
||||
|
||||
return projections;
|
||||
}
|
||||
|
||||
private void updateBackgroundDrawableProjections() {
|
||||
Set<Projection> projections = Stream.of(bodyBubble.getProjections())
|
||||
.map(p -> Projection.translateFromDescendantToParentCoords(p, bodyBubble, this))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (messageRecord.isOutgoing() &&
|
||||
!hasNoBubble(messageRecord) &&
|
||||
bodyBubbleCorners != null)
|
||||
{
|
||||
projections.add(Projection.relativeToParent(this, bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX()));
|
||||
}
|
||||
|
||||
backgroundDrawable.setProjections(projections);
|
||||
}
|
||||
|
||||
private class SharedContactEventListener implements SharedContactView.EventListener {
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.LinearLayout;
|
||||
@@ -10,19 +9,27 @@ import android.widget.LinearLayout;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.components.Outliner;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
public class ConversationItemBodyBubble extends LinearLayout {
|
||||
|
||||
@Nullable private List<Outliner> outliners = Collections.emptyList();
|
||||
@Nullable private OnSizeChangedListener sizeChangedListener;
|
||||
|
||||
private MaskDrawable maskDrawable;
|
||||
private Rect mask;
|
||||
private ClipProjectionDrawable clipProjectionDrawable;
|
||||
private Projection quoteViewProjection;
|
||||
private Projection videoPlayerProjection;
|
||||
|
||||
public ConversationItemBodyBubble(Context context) {
|
||||
super(context);
|
||||
@@ -46,14 +53,26 @@ public class ConversationItemBodyBubble extends LinearLayout {
|
||||
|
||||
@Override
|
||||
public void setBackground(Drawable background) {
|
||||
maskDrawable = new MaskDrawable(background);
|
||||
maskDrawable.setMask(mask);
|
||||
super.setBackground(maskDrawable);
|
||||
clipProjectionDrawable = new ClipProjectionDrawable(background);
|
||||
|
||||
clipProjectionDrawable.setProjections(getProjections());
|
||||
super.setBackground(clipProjectionDrawable);
|
||||
}
|
||||
|
||||
public void setMask(@Nullable Rect mask) {
|
||||
this.mask = mask;
|
||||
maskDrawable.setMask(mask);
|
||||
public void setQuoteViewProjection(@Nullable Projection quoteViewProjection) {
|
||||
this.quoteViewProjection = quoteViewProjection;
|
||||
clipProjectionDrawable.setProjections(getProjections());
|
||||
}
|
||||
|
||||
public void setVideoPlayerProjection(@Nullable Projection videoPlayerProjection) {
|
||||
this.videoPlayerProjection = videoPlayerProjection;
|
||||
clipProjectionDrawable.setProjections(getProjections());
|
||||
}
|
||||
|
||||
public @NonNull Set<Projection> getProjections() {
|
||||
return Stream.of(quoteViewProjection, videoPlayerProjection)
|
||||
.filterNot(Objects::isNull)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -2,23 +2,30 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.components.CornerMask;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.components.MaskView;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Masking area to ensure proper rendering of Reactions overlay.
|
||||
*/
|
||||
public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
|
||||
|
||||
private final ConversationItem conversationItem;
|
||||
private final View videoContainer;
|
||||
private final Paint paint;
|
||||
|
||||
public ConversationItemMaskTarget(@NonNull ConversationItem conversationItem,
|
||||
@Nullable View videoContainer)
|
||||
@@ -26,6 +33,10 @@ public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
|
||||
super(conversationItem);
|
||||
this.conversationItem = conversationItem;
|
||||
this.videoContainer = videoContainer;
|
||||
this.paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
|
||||
paint.setColor(Color.BLACK);
|
||||
paint.setStyle(Paint.Style.FILL);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -41,19 +52,16 @@ public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
|
||||
protected void draw(@NonNull Canvas canvas) {
|
||||
super.draw(canvas);
|
||||
|
||||
if (videoContainer == null) {
|
||||
return;
|
||||
List<Projection> projections = Stream.of(conversationItem.getColorizerProjections()).map(p ->
|
||||
Projection.translateFromRootToDescendantCoords(p, conversationItem)
|
||||
).toList();
|
||||
|
||||
if (videoContainer != null) {
|
||||
projections.add(conversationItem.getProjection((RecyclerView) conversationItem.getParent()));
|
||||
}
|
||||
|
||||
GiphyMp4Projection projection = conversationItem.getProjection((RecyclerView) conversationItem.getParent());
|
||||
CornerMask cornerMask = projection.getCornerMask();
|
||||
|
||||
canvas.clipRect(conversationItem.bodyBubble.getLeft(),
|
||||
conversationItem.bodyBubble.getTop(),
|
||||
conversationItem.bodyBubble.getRight(),
|
||||
conversationItem.bodyBubble.getTop() + projection.getHeight());
|
||||
|
||||
canvas.drawColor(Color.BLACK);
|
||||
cornerMask.mask(canvas);
|
||||
for (Projection projection : projections) {
|
||||
canvas.drawPath(projection.getPath(), paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
|
||||
import org.thoughtcrime.securesms.util.AccessibilityUtil;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
|
||||
class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
|
||||
public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
|
||||
|
||||
private static float SWIPE_SUCCESS_DX = ConversationSwipeAnimationHelper.TRIGGER_DX;
|
||||
private static long SWIPE_SUCCESS_VIBE_TIME_MS = 10;
|
||||
@@ -30,17 +30,17 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
|
||||
private final SwipeAvailabilityProvider swipeAvailabilityProvider;
|
||||
private final ConversationItemTouchListener itemTouchListener;
|
||||
private final OnSwipeListener onSwipeListener;
|
||||
private final GiphyMp4DisplayUpdater giphyMp4DisplayUpdater;
|
||||
private final OnViewHolderTranslated onViewHolderTranslated;
|
||||
|
||||
ConversationItemSwipeCallback(@NonNull SwipeAvailabilityProvider swipeAvailabilityProvider,
|
||||
@NonNull OnSwipeListener onSwipeListener,
|
||||
@NonNull GiphyMp4DisplayUpdater giphyMp4DisplayUpdater)
|
||||
@NonNull OnViewHolderTranslated onViewHolderTranslated)
|
||||
{
|
||||
super(0, ItemTouchHelper.END);
|
||||
this.itemTouchListener = new ConversationItemTouchListener(this::updateLatestDownCoordinate);
|
||||
this.swipeAvailabilityProvider = swipeAvailabilityProvider;
|
||||
this.onSwipeListener = onSwipeListener;
|
||||
this.giphyMp4DisplayUpdater = giphyMp4DisplayUpdater;
|
||||
this.onViewHolderTranslated = onViewHolderTranslated;
|
||||
this.shouldTriggerSwipeFeedback = true;
|
||||
this.canTriggerSwipe = true;
|
||||
}
|
||||
@@ -93,14 +93,14 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
|
||||
|
||||
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) {
|
||||
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign);
|
||||
updateVideoPlayer(recyclerView, viewHolder);
|
||||
dispatchTranslationUpdate(recyclerView, viewHolder);
|
||||
handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx));
|
||||
if (canTriggerSwipe) {
|
||||
setTouchListener(recyclerView, viewHolder, Math.abs(dx));
|
||||
}
|
||||
} else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) {
|
||||
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1);
|
||||
updateVideoPlayer(recyclerView, viewHolder);
|
||||
dispatchTranslationUpdate(recyclerView, viewHolder);
|
||||
}
|
||||
|
||||
if (dx == 0) {
|
||||
@@ -109,10 +109,8 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateVideoPlayer(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
if (viewHolder instanceof GiphyMp4Playable) {
|
||||
giphyMp4DisplayUpdater.updateDisplay(recyclerView, (GiphyMp4Playable) viewHolder);
|
||||
}
|
||||
private void dispatchTranslationUpdate(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
onViewHolderTranslated.onViewHolderTranslated(recyclerView, viewHolder);
|
||||
}
|
||||
|
||||
private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) {
|
||||
@@ -174,7 +172,7 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
|
||||
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView,
|
||||
0f,
|
||||
getSignFromDirection(viewHolder.itemView));
|
||||
updateVideoPlayer(recyclerView, viewHolder);
|
||||
dispatchTranslationUpdate(recyclerView, viewHolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,4 +209,8 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
|
||||
interface OnSwipeListener {
|
||||
void onSwipe(ConversationMessage conversationMessage);
|
||||
}
|
||||
|
||||
public interface OnViewHolderTranslated {
|
||||
void onViewHolderTranslated(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
|
||||
@@ -24,6 +23,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.VerifyIdentityActivity;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil;
|
||||
@@ -31,12 +31,12 @@ import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.UpdateDescription;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
@@ -47,6 +47,8 @@ import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
@@ -109,7 +111,8 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
boolean hasWallpaper,
|
||||
boolean isMessageRequestAccepted,
|
||||
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
|
||||
boolean allowedToPlayInline)
|
||||
boolean allowedToPlayInline,
|
||||
@NonNull Colorizer colorizer)
|
||||
{
|
||||
this.batchSelected = batchSelected;
|
||||
|
||||
@@ -206,7 +209,7 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
|
||||
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) {
|
||||
throw new UnsupportedOperationException("ConversationUpdateItems cannot be projected into.");
|
||||
}
|
||||
|
||||
@@ -215,6 +218,11 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull List<Projection> getColorizerProjections() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
static final class RecipientObserverManager {
|
||||
|
||||
private final Observer<Recipient> recipientObserver;
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
@@ -18,8 +20,13 @@ import org.signal.paging.PagedData;
|
||||
import org.signal.paging.PagingConfig;
|
||||
import org.signal.paging.PagingController;
|
||||
import org.signal.paging.ProxyPagingController;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
|
||||
import org.thoughtcrime.securesms.conversation.colors.NameColor;
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaRepository;
|
||||
import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent;
|
||||
@@ -30,8 +37,11 @@ import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public class ConversationViewModel extends ViewModel {
|
||||
|
||||
@@ -52,6 +62,7 @@ public class ConversationViewModel extends ViewModel {
|
||||
private final MutableLiveData<RecipientId> recipientId;
|
||||
private final LiveData<ChatWallpaper> wallpaper;
|
||||
private final SingleLiveEvent<Event> events;
|
||||
private final LiveData<ChatColors> chatColors;
|
||||
|
||||
private ConversationIntents.Args args;
|
||||
private int jumpToPosition;
|
||||
@@ -114,11 +125,15 @@ public class ConversationViewModel extends ViewModel {
|
||||
|
||||
conversationMetadata = Transformations.switchMap(messages, m -> metadata);
|
||||
canShowAsBubble = LiveDataUtil.mapAsync(threadId, conversationRepository::canShowAsBubble);
|
||||
wallpaper = Transformations.distinctUntilChanged(Transformations.map(Transformations.switchMap(recipientId,
|
||||
id -> Recipient.live(id).getLiveData()),
|
||||
Recipient::getWallpaper));
|
||||
wallpaper = LiveDataUtil.mapDistinct(Transformations.switchMap(recipientId,
|
||||
id -> Recipient.live(id).getLiveData()),
|
||||
Recipient::getWallpaper);
|
||||
|
||||
EventBus.getDefault().register(this);
|
||||
|
||||
chatColors = LiveDataUtil.mapDistinct(Transformations.switchMap(recipientId,
|
||||
id -> Recipient.live(id).getLiveData()),
|
||||
Recipient::getChatColors);
|
||||
}
|
||||
|
||||
void onAttachmentKeyboardOpen() {
|
||||
@@ -159,6 +174,10 @@ public class ConversationViewModel extends ViewModel {
|
||||
return events;
|
||||
}
|
||||
|
||||
@NonNull LiveData<ChatColors> getChatColors() {
|
||||
return chatColors;
|
||||
}
|
||||
|
||||
void setHasUnreadMentions(boolean hasUnreadMentions) {
|
||||
this.hasUnreadMentions.setValue(hasUnreadMentions);
|
||||
}
|
||||
@@ -183,6 +202,35 @@ public class ConversationViewModel extends ViewModel {
|
||||
return pagingController;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Map<RecipientId, NameColor>> getNameColorsMap() {
|
||||
LiveData<Recipient> recipient = Transformations.switchMap(recipientId, r -> Recipient.live(r).getLiveData());
|
||||
LiveData<Recipient> groupRecipients = LiveDataUtil.filter(recipient, Recipient::isGroup);
|
||||
LiveData<List<GroupMemberEntry.FullMember>> groupMembers = Transformations.switchMap(groupRecipients, r -> new LiveGroup(r.getGroupId().get()).getFullMembers());
|
||||
|
||||
return Transformations.map(groupMembers, members -> {
|
||||
List<GroupMemberEntry.FullMember> sorted = Stream.of(members)
|
||||
.filter(member -> !Objects.equals(member.getMember(), Recipient.self()))
|
||||
.sortBy(this::getMemberIdentifier)
|
||||
.toList();
|
||||
|
||||
List<NameColor> names = ChatColorsPalette.Names.getAll();
|
||||
Map<RecipientId, NameColor> colors = new HashMap<>();
|
||||
for (int i = 0; i < sorted.size(); i++) {
|
||||
colors.put(sorted.get(i).getMember().getId(), names.get(i % names.size()));
|
||||
}
|
||||
|
||||
return colors;
|
||||
});
|
||||
}
|
||||
|
||||
private @NonNull String getMemberIdentifier(@NonNull GroupMemberEntry.FullMember fullMember) {
|
||||
return fullMember.getMember()
|
||||
.getUuid()
|
||||
.transform(UUID::toString)
|
||||
.or(fullMember.getMember().getE164())
|
||||
.or("");
|
||||
}
|
||||
|
||||
long getLastSeen() {
|
||||
return conversationMetadata.getValue() != null ? conversationMetadata.getValue().getLastSeen() : 0;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Drawable which lets you punch a hole through another drawable.
|
||||
*
|
||||
* TODO: Remove in favor of ClipProjectionDrawable
|
||||
*/
|
||||
public final class MaskDrawable extends Drawable {
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Path
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import com.google.common.base.Objects
|
||||
import org.thoughtcrime.securesms.components.RotatableGradientDrawable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
|
||||
import org.thoughtcrime.securesms.util.customizeOnDraw
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* ChatColors represent how to render the avatar and bubbles in a given context.
|
||||
*
|
||||
* @param id The identifier for this chat color. It is either BuiltIn, NotSet, or Custom(long)
|
||||
* @param linearGradient The LinearGradient to render. Null if this is for a single color.
|
||||
* @param singleColor The single color to render. Null if this is for a linear gradient.
|
||||
*/
|
||||
class ChatColors private constructor(
|
||||
val id: Id,
|
||||
private val linearGradient: LinearGradient?,
|
||||
private val singleColor: Int?
|
||||
) {
|
||||
|
||||
/**
|
||||
* Returns the Drawable to render the linear gradient, or null if this ChatColors is a single color.
|
||||
*/
|
||||
val chatBubbleMask: Drawable
|
||||
get() = linearGradient?.let {
|
||||
RotatableGradientDrawable(
|
||||
it.degrees,
|
||||
it.colors,
|
||||
it.positions
|
||||
)
|
||||
} ?: ColorDrawable(asSingleColor())
|
||||
|
||||
/**
|
||||
* Returns the ColorFilter to apply to a conversation bubble or other relevant piece of UI.
|
||||
*/
|
||||
val chatBubbleColorFilter: ColorFilter = PorterDuffColorFilter(Color.TRANSPARENT, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
@ColorInt
|
||||
fun asSingleColor(): Int {
|
||||
if (singleColor != null) {
|
||||
return singleColor
|
||||
}
|
||||
|
||||
if (linearGradient != null) {
|
||||
val start = linearGradient.colors.first()
|
||||
val end = linearGradient.colors.last()
|
||||
|
||||
return ColorUtils.blendARGB(start, end, 0.5f)
|
||||
}
|
||||
|
||||
throw AssertionError()
|
||||
}
|
||||
|
||||
fun serialize(): ChatColor {
|
||||
val builder: ChatColor.Builder = ChatColor.newBuilder()
|
||||
|
||||
if (linearGradient != null) {
|
||||
val gradientBuilder = ChatColor.LinearGradient.newBuilder()
|
||||
|
||||
gradientBuilder.rotation = linearGradient.degrees
|
||||
linearGradient.colors.forEach { gradientBuilder.addColors(it) }
|
||||
linearGradient.positions.forEach { gradientBuilder.addPositions(it) }
|
||||
|
||||
builder.setLinearGradient(gradientBuilder)
|
||||
}
|
||||
|
||||
if (singleColor != null) {
|
||||
builder.setSingleColor(ChatColor.SingleColor.newBuilder().setColor(singleColor))
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun getColors(): IntArray {
|
||||
return linearGradient?.colors ?: if (singleColor != null) {
|
||||
intArrayOf(singleColor)
|
||||
} else {
|
||||
throw AssertionError()
|
||||
}
|
||||
}
|
||||
|
||||
fun getDegrees(): Float {
|
||||
return linearGradient?.degrees ?: 180f
|
||||
}
|
||||
|
||||
fun asCircle(): Drawable {
|
||||
val toWrap: Drawable = chatBubbleMask
|
||||
val path = Path()
|
||||
|
||||
return toWrap.customizeOnDraw { wrapped, canvas ->
|
||||
canvas.save()
|
||||
path.rewind()
|
||||
path.addCircle(
|
||||
wrapped.bounds.exactCenterX(),
|
||||
wrapped.bounds.exactCenterY(),
|
||||
min(wrapped.bounds.exactCenterX(), wrapped.bounds.exactCenterY()),
|
||||
Path.Direction.CW
|
||||
)
|
||||
canvas.clipPath(path)
|
||||
wrapped.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
||||
fun withId(id: Id): ChatColors = ChatColors(id, linearGradient, singleColor)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
val otherChatColors: ChatColors = (other as? ChatColors) ?: return false
|
||||
|
||||
if (id != otherChatColors.id) return false
|
||||
if (linearGradient != otherChatColors.linearGradient) return false
|
||||
if (singleColor != otherChatColors.singleColor) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return Objects.hashCode(linearGradient, singleColor, id)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun forChatColor(id: Id, chatColor: ChatColor): ChatColors {
|
||||
assert(chatColor.hasSingleColor() xor chatColor.hasLinearGradient())
|
||||
|
||||
return if (chatColor.hasLinearGradient()) {
|
||||
val linearGradient = LinearGradient(
|
||||
chatColor.linearGradient.rotation,
|
||||
chatColor.linearGradient.colorsList.toIntArray(),
|
||||
chatColor.linearGradient.positionsList.toFloatArray()
|
||||
)
|
||||
|
||||
forGradient(id, linearGradient)
|
||||
} else {
|
||||
val singleColor = chatColor.singleColor.color
|
||||
|
||||
forColor(id, singleColor)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun forGradient(id: Id, linearGradient: LinearGradient): ChatColors =
|
||||
ChatColors(id, linearGradient, null)
|
||||
|
||||
@JvmStatic
|
||||
fun forColor(id: Id, @ColorInt color: Int): ChatColors =
|
||||
ChatColors(id, null, color)
|
||||
}
|
||||
|
||||
sealed class Id(val longValue: Long) {
|
||||
/**
|
||||
* Represents user selection of 'auto'.
|
||||
*/
|
||||
object Auto : Id(-2)
|
||||
|
||||
/**
|
||||
* Represents a built in color.
|
||||
*/
|
||||
object BuiltIn : Id(-1)
|
||||
|
||||
/**
|
||||
* Represents an unsaved or un-set option.
|
||||
*/
|
||||
object NotSet : Id(0)
|
||||
|
||||
/**
|
||||
* Represents a custom created ChatColors.
|
||||
*/
|
||||
class Custom internal constructor(id: Long) : Id(id)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return longValue == (other as? Id)?.longValue
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return Objects.hashCode(longValue)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun forLongValue(longValue: Long): Id {
|
||||
return when (longValue) {
|
||||
-2L -> Auto
|
||||
-1L -> BuiltIn
|
||||
0L -> NotSet
|
||||
else -> Custom(longValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LinearGradient(
|
||||
val degrees: Float,
|
||||
val colors: IntArray,
|
||||
val positions: FloatArray
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as LinearGradient
|
||||
|
||||
if (!colors.contentEquals(other.colors)) return false
|
||||
if (!positions.contentEquals(other.positions)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = colors.contentHashCode()
|
||||
result = 31 * result + positions.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors
|
||||
|
||||
import com.google.common.collect.BiMap
|
||||
import com.google.common.collect.ImmutableBiMap
|
||||
import com.google.common.collect.ImmutableMap
|
||||
import org.thoughtcrime.securesms.color.MaterialColor
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import org.thoughtcrime.securesms.wallpaper.GradientChatWallpaper
|
||||
import org.thoughtcrime.securesms.wallpaper.SingleColorChatWallpaper
|
||||
|
||||
/**
|
||||
* Contains mappings to get the relevant chat colors for either a legacy MaterialColor or a built-in wallpaper.
|
||||
*/
|
||||
object ChatColorsMapper {
|
||||
|
||||
private val materialColorToChatColorsBiMap: BiMap<MaterialColor, ChatColors> = ImmutableBiMap.Builder<MaterialColor, ChatColors>().apply {
|
||||
put(MaterialColor.CRIMSON, ChatColorsPalette.Bubbles.CRIMSON)
|
||||
put(MaterialColor.VERMILLION, ChatColorsPalette.Bubbles.VERMILION)
|
||||
put(MaterialColor.BURLAP, ChatColorsPalette.Bubbles.BURLAP)
|
||||
put(MaterialColor.FOREST, ChatColorsPalette.Bubbles.FOREST)
|
||||
put(MaterialColor.WINTERGREEN, ChatColorsPalette.Bubbles.WINTERGREEN)
|
||||
put(MaterialColor.TEAL, ChatColorsPalette.Bubbles.TEAL)
|
||||
put(MaterialColor.BLUE, ChatColorsPalette.Bubbles.BLUE)
|
||||
put(MaterialColor.INDIGO, ChatColorsPalette.Bubbles.INDIGO)
|
||||
put(MaterialColor.VIOLET, ChatColorsPalette.Bubbles.VIOLET)
|
||||
put(MaterialColor.PLUM, ChatColorsPalette.Bubbles.PLUM)
|
||||
put(MaterialColor.TAUPE, ChatColorsPalette.Bubbles.TAUPE)
|
||||
put(MaterialColor.STEEL, ChatColorsPalette.Bubbles.STEEL)
|
||||
put(MaterialColor.ULTRAMARINE, ChatColorsPalette.Bubbles.ULTRAMARINE)
|
||||
}.build()
|
||||
|
||||
private val wallpaperToChatColorsMap: Map<ChatWallpaper, ChatColors> = ImmutableMap.Builder<ChatWallpaper, ChatColors>().apply {
|
||||
put(SingleColorChatWallpaper.BLUSH, ChatColorsPalette.Bubbles.CRIMSON)
|
||||
put(SingleColorChatWallpaper.COPPER, ChatColorsPalette.Bubbles.VERMILION)
|
||||
put(SingleColorChatWallpaper.DUST, ChatColorsPalette.Bubbles.BURLAP)
|
||||
put(SingleColorChatWallpaper.CELADON, ChatColorsPalette.Bubbles.FOREST)
|
||||
put(SingleColorChatWallpaper.RAINFOREST, ChatColorsPalette.Bubbles.WINTERGREEN)
|
||||
put(SingleColorChatWallpaper.PACIFIC, ChatColorsPalette.Bubbles.TEAL)
|
||||
put(SingleColorChatWallpaper.FROST, ChatColorsPalette.Bubbles.BLUE)
|
||||
put(SingleColorChatWallpaper.NAVY, ChatColorsPalette.Bubbles.INDIGO)
|
||||
put(SingleColorChatWallpaper.LILAC, ChatColorsPalette.Bubbles.VIOLET)
|
||||
put(SingleColorChatWallpaper.PINK, ChatColorsPalette.Bubbles.PLUM)
|
||||
put(SingleColorChatWallpaper.EGGPLANT, ChatColorsPalette.Bubbles.TAUPE)
|
||||
put(SingleColorChatWallpaper.SILVER, ChatColorsPalette.Bubbles.STEEL)
|
||||
put(GradientChatWallpaper.SUNSET, ChatColorsPalette.Bubbles.EMBER)
|
||||
put(GradientChatWallpaper.NOIR, ChatColorsPalette.Bubbles.MIDNIGHT)
|
||||
put(GradientChatWallpaper.HEATMAP, ChatColorsPalette.Bubbles.INFRARED)
|
||||
put(GradientChatWallpaper.AQUA, ChatColorsPalette.Bubbles.LAGOON)
|
||||
put(GradientChatWallpaper.IRIDESCENT, ChatColorsPalette.Bubbles.FLUORESCENT)
|
||||
put(GradientChatWallpaper.MONSTERA, ChatColorsPalette.Bubbles.BASIL)
|
||||
put(GradientChatWallpaper.BLISS, ChatColorsPalette.Bubbles.SUBLIME)
|
||||
put(GradientChatWallpaper.SKY, ChatColorsPalette.Bubbles.SEA)
|
||||
put(GradientChatWallpaper.PEACH, ChatColorsPalette.Bubbles.TANGERINE)
|
||||
}.build()
|
||||
|
||||
@JvmStatic
|
||||
val entrySet: Set<MutableMap.MutableEntry<MaterialColor, ChatColors>>
|
||||
get() = materialColorToChatColorsBiMap.entries
|
||||
|
||||
@JvmStatic
|
||||
fun getChatColors(materialColor: MaterialColor): ChatColors {
|
||||
return materialColorToChatColorsBiMap[materialColor] ?: ChatColorsPalette.Bubbles.default
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getChatColors(wallpaper: ChatWallpaper): ChatColors {
|
||||
return wallpaperToChatColorsMap.entries.find { (key, _) ->
|
||||
key.isSameSource(wallpaper)
|
||||
}?.value ?: ChatColorsPalette.Bubbles.default
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getMaterialColor(chatColors: ChatColors): MaterialColor {
|
||||
return materialColorToChatColorsBiMap.inverse()[chatColors] ?: MaterialColor.ULTRAMARINE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors
|
||||
|
||||
/**
|
||||
* Namespaced collection of supported bubble colors and name colors.
|
||||
*/
|
||||
object ChatColorsPalette {
|
||||
object Bubbles {
|
||||
|
||||
// region Default
|
||||
|
||||
@JvmField
|
||||
val ULTRAMARINE = ChatColors.forGradient(
|
||||
ChatColors.Id.BuiltIn,
|
||||
ChatColors.LinearGradient(
|
||||
180.0f,
|
||||
intArrayOf(0xFF0552F0.toInt(), 0xFF2C6BED.toInt()),
|
||||
floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
// region Solids
|
||||
|
||||
@JvmField
|
||||
val CRIMSON = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFFCF16E3.toInt())
|
||||
|
||||
@JvmField
|
||||
val VERMILION = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFFC73F0A.toInt())
|
||||
|
||||
@JvmField
|
||||
val BURLAP = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF6F6A58.toInt())
|
||||
|
||||
@JvmField
|
||||
val FOREST = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF3B7845.toInt())
|
||||
|
||||
@JvmField
|
||||
val WINTERGREEN = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF1D8663.toInt())
|
||||
|
||||
@JvmField
|
||||
val TEAL = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF077D92.toInt())
|
||||
|
||||
@JvmField
|
||||
val BLUE = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF336BA3.toInt())
|
||||
|
||||
@JvmField
|
||||
val INDIGO = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF6058CA.toInt())
|
||||
|
||||
@JvmField
|
||||
val VIOLET = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF9932CB.toInt())
|
||||
|
||||
@JvmField
|
||||
val PLUM = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFFAA377A.toInt())
|
||||
|
||||
@JvmField
|
||||
val TAUPE = ChatColors.forColor(
|
||||
ChatColors.Id.BuiltIn, 0xFF8F616A.toInt()
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val STEEL = ChatColors.forColor(
|
||||
ChatColors.Id.BuiltIn, 0xFF71717F.toInt()
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
// region Gradients
|
||||
|
||||
@JvmField
|
||||
val EMBER = ChatColors.forGradient(
|
||||
ChatColors.Id.BuiltIn,
|
||||
ChatColors.LinearGradient(
|
||||
168f,
|
||||
intArrayOf(0xFFE57C00.toInt(), 0xFF5E0000.toInt()),
|
||||
floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val MIDNIGHT = ChatColors.forGradient(
|
||||
ChatColors.Id.BuiltIn,
|
||||
ChatColors.LinearGradient(
|
||||
180f,
|
||||
intArrayOf(0xFF2C2C3A.toInt(), 0xFF787891.toInt()),
|
||||
floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val INFRARED = ChatColors.forGradient(
|
||||
ChatColors.Id.BuiltIn,
|
||||
ChatColors.LinearGradient(
|
||||
192f,
|
||||
intArrayOf(0xFFF65560.toInt(), 0xFF442CED.toInt()),
|
||||
floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val LAGOON = ChatColors.forGradient(
|
||||
ChatColors.Id.BuiltIn,
|
||||
ChatColors.LinearGradient(
|
||||
180f,
|
||||
intArrayOf(0xFF004066.toInt(), 0xFF32867D.toInt()),
|
||||
floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val FLUORESCENT = ChatColors.forGradient(
|
||||
ChatColors.Id.BuiltIn,
|
||||
ChatColors.LinearGradient(
|
||||
192f,
|
||||
intArrayOf(0xFFEC13DD.toInt(), 0xFF1B36C6.toInt()),
|
||||
floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val BASIL = ChatColors.forGradient(
|
||||
ChatColors.Id.BuiltIn,
|
||||
ChatColors.LinearGradient(
|
||||
180f,
|
||||
intArrayOf(0xFF2F9373.toInt(), 0xFF077343.toInt()),
|
||||
floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val SUBLIME = ChatColors.forGradient(
|
||||
ChatColors.Id.BuiltIn,
|
||||
ChatColors.LinearGradient(
|
||||
180f,
|
||||
intArrayOf(
|
||||
0xFF6281D5.toInt(), 0xFF974460.toInt()
|
||||
),
|
||||
floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val SEA = ChatColors.forGradient(
|
||||
ChatColors.Id.BuiltIn,
|
||||
ChatColors.LinearGradient(
|
||||
180f,
|
||||
intArrayOf(0xFF498FD4.toInt(), 0xFF2C66A0.toInt()),
|
||||
floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val TANGERINE = ChatColors.forGradient(
|
||||
ChatColors.Id.BuiltIn,
|
||||
ChatColors.LinearGradient(
|
||||
192f,
|
||||
intArrayOf(0xFFDB7133.toInt(), 0xFF911231.toInt()),
|
||||
floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
@JvmStatic
|
||||
val default = ULTRAMARINE
|
||||
|
||||
val solids = listOf(
|
||||
CRIMSON,
|
||||
VERMILION,
|
||||
BURLAP,
|
||||
FOREST,
|
||||
WINTERGREEN,
|
||||
TEAL,
|
||||
BLUE,
|
||||
INDIGO,
|
||||
VIOLET,
|
||||
PLUM,
|
||||
TAUPE,
|
||||
STEEL
|
||||
)
|
||||
val gradients =
|
||||
listOf(EMBER, MIDNIGHT, INFRARED, LAGOON, FLUORESCENT, BASIL, SUBLIME, SEA, TANGERINE)
|
||||
val all = listOf(default) + solids + gradients
|
||||
}
|
||||
|
||||
object Names {
|
||||
@JvmStatic
|
||||
val all = listOf(
|
||||
NameColor(lightColor = 0xFFD00B0B.toInt(), darkColor = 0xFFF76E6E.toInt()),
|
||||
NameColor(lightColor = 0xFF067906.toInt(), darkColor = 0xFF0AB80A.toInt()),
|
||||
NameColor(lightColor = 0xFF5151F6.toInt(), darkColor = 0xFF8B8BF9.toInt()),
|
||||
NameColor(lightColor = 0xFF866118.toInt(), darkColor = 0xFFD08F0B.toInt()),
|
||||
NameColor(lightColor = 0xFF067953.toInt(), darkColor = 0xFF09B37B.toInt()),
|
||||
NameColor(lightColor = 0xFFA20CED.toInt(), darkColor = 0xFFCB72F8.toInt()),
|
||||
NameColor(lightColor = 0xFF507406.toInt(), darkColor = 0xFF77AE09.toInt()),
|
||||
NameColor(lightColor = 0xFF086DA0.toInt(), darkColor = 0xFF0DA6F2.toInt()),
|
||||
NameColor(lightColor = 0xFFC70A88.toInt(), darkColor = 0xFFF76EC9.toInt()),
|
||||
NameColor(lightColor = 0xFFB34209.toInt(), darkColor = 0xFFF4702F.toInt()),
|
||||
NameColor(lightColor = 0xFF06792D.toInt(), darkColor = 0xFF0AB844.toInt()),
|
||||
NameColor(lightColor = 0xFF7A3DF5.toInt(), darkColor = 0xFFAC86F9.toInt()),
|
||||
NameColor(lightColor = 0xFF6C6C13.toInt(), darkColor = 0xFFA5A509.toInt()),
|
||||
NameColor(lightColor = 0xFF067474.toInt(), darkColor = 0xFF09AEAE.toInt()),
|
||||
NameColor(lightColor = 0xFFB80AB8.toInt(), darkColor = 0xFFF75FF7.toInt()),
|
||||
NameColor(lightColor = 0xFF2D7906.toInt(), darkColor = 0xFF42B309.toInt()),
|
||||
NameColor(lightColor = 0xFF0D59F2.toInt(), darkColor = 0xFF6495F7.toInt()),
|
||||
NameColor(lightColor = 0xFFD00B4D.toInt(), darkColor = 0xFFF76998.toInt()),
|
||||
NameColor(lightColor = 0xFFC72A0A.toInt(), darkColor = 0xFFF67055.toInt()),
|
||||
NameColor(lightColor = 0xFF067919.toInt(), darkColor = 0xFF0AB827.toInt()),
|
||||
NameColor(lightColor = 0xFF6447F5.toInt(), darkColor = 0xFF9986F9.toInt()),
|
||||
NameColor(lightColor = 0xFF76681E.toInt(), darkColor = 0xFFB89B0A.toInt()),
|
||||
NameColor(lightColor = 0xFF067462.toInt(), darkColor = 0xFF09B397.toInt()),
|
||||
NameColor(lightColor = 0xFFAF0BD0.toInt(), darkColor = 0xFFE06EF7.toInt()),
|
||||
NameColor(lightColor = 0xFF3D7406.toInt(), darkColor = 0xFF5EB309.toInt()),
|
||||
NameColor(lightColor = 0xFF0A69C7.toInt(), darkColor = 0xFF429CF5.toInt()),
|
||||
NameColor(lightColor = 0xFFCB0B6B.toInt(), darkColor = 0xFFF76EB2.toInt()),
|
||||
NameColor(lightColor = 0xFF9C5711.toInt(), darkColor = 0xFFE97A0C.toInt()),
|
||||
NameColor(lightColor = 0xFF067940.toInt(), darkColor = 0xFF09B35E.toInt()),
|
||||
NameColor(lightColor = 0xFF8F2AF4.toInt(), darkColor = 0xFFBD81F8.toInt()),
|
||||
NameColor(lightColor = 0xFF5E6E0C.toInt(), darkColor = 0xFF8FAA09.toInt()),
|
||||
NameColor(lightColor = 0xFF077288.toInt(), darkColor = 0xFF0BABCB.toInt()),
|
||||
NameColor(lightColor = 0xFFC20AA3.toInt(), darkColor = 0xFFF75FDD.toInt()),
|
||||
NameColor(lightColor = 0xFF1A7906.toInt(), darkColor = 0xFF27B80A.toInt()),
|
||||
NameColor(lightColor = 0xFF3454F4.toInt(), darkColor = 0xFF778DF8.toInt()),
|
||||
NameColor(lightColor = 0xFFD00B2C.toInt(), darkColor = 0xFFF76E85.toInt())
|
||||
)
|
||||
}
|
||||
|
||||
@JvmField
|
||||
val UNKNOWN_CONTACT = Bubbles.STEEL
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors
|
||||
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
|
||||
/**
|
||||
* Denotes that a class can be colorized. The class is responsible for
|
||||
* generating its own projection.
|
||||
*/
|
||||
interface Colorizable {
|
||||
val colorizerProjections: List<Projection>
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
|
||||
/**
|
||||
* Helper class for all things ChatColors.
|
||||
*
|
||||
* - Maintains a mapping for group recipient colors
|
||||
* - Gives easy access to different bubble colors
|
||||
* - Watches and responds to RecyclerView scroll and layout changes to update a ColorizerView
|
||||
*/
|
||||
class Colorizer(private val colorizerView: ColorizerView) : RecyclerView.OnScrollListener(), View.OnLayoutChangeListener {
|
||||
|
||||
private val groupSenderColors: MutableMap<RecipientId, NameColor> = mutableMapOf()
|
||||
|
||||
@ColorInt
|
||||
fun getOutgoingBodyTextColor(context: Context): Int {
|
||||
return ContextCompat.getColor(context, R.color.white)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun getOutgoingFooterTextColor(context: Context): Int {
|
||||
return ContextCompat.getColor(context, R.color.conversation_item_outgoing_footer_fg)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun getOutgoingFooterIconColor(context: Context): Int {
|
||||
return ContextCompat.getColor(context, R.color.conversation_item_outgoing_footer_fg)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun getIncomingGroupSenderColor(context: Context, recipient: Recipient): Int = groupSenderColors[recipient.id]?.getColor(context) ?: Color.TRANSPARENT
|
||||
|
||||
fun attachToRecyclerView(recyclerView: RecyclerView) {
|
||||
recyclerView.addOnScrollListener(this)
|
||||
recyclerView.addOnLayoutChangeListener(this)
|
||||
}
|
||||
|
||||
fun onNameColorsChanged(nameColorMap: Map<RecipientId, NameColor>) {
|
||||
groupSenderColors.clear()
|
||||
groupSenderColors.putAll(nameColorMap)
|
||||
}
|
||||
|
||||
fun onChatColorsChanged(chatColors: ChatColors) {
|
||||
colorizerView.background = chatColors.chatBubbleMask
|
||||
}
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
applyClipPathsToMaskedGradient(recyclerView)
|
||||
}
|
||||
|
||||
override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
|
||||
applyClipPathsToMaskedGradient(v as RecyclerView)
|
||||
}
|
||||
|
||||
fun applyClipPathsToMaskedGradient(recyclerView: RecyclerView) {
|
||||
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||
|
||||
val firstVisibleItemPosition: Int = layoutManager.findFirstVisibleItemPosition()
|
||||
val lastVisibleItemPosition: Int = layoutManager.findLastVisibleItemPosition()
|
||||
|
||||
val projections: List<Projection> = (firstVisibleItemPosition..lastVisibleItemPosition)
|
||||
.mapNotNull { recyclerView.findViewHolderForAdapterPosition(it) as? Colorizable }
|
||||
.map {
|
||||
it.colorizerProjections
|
||||
.map { p -> Projection.translateFromRootToDescendantCoords(p, colorizerView) }
|
||||
}
|
||||
.flatten()
|
||||
|
||||
if (projections.isNotEmpty()) {
|
||||
colorizerView.visibility = View.VISIBLE
|
||||
colorizerView.setProjections(projections)
|
||||
} else {
|
||||
colorizerView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Path
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
|
||||
/**
|
||||
* ColorizerView takes a list of projections and uses them to create a mask over it's background.
|
||||
*/
|
||||
class ColorizerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val clipPath = Path()
|
||||
private var projections: List<Projection> = listOf()
|
||||
|
||||
fun setProjections(projections: List<Projection>) {
|
||||
this.projections = projections
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
if (projections.isNotEmpty()) {
|
||||
canvas.save()
|
||||
clipPath.rewind()
|
||||
|
||||
projections.forEach {
|
||||
it.applyToPath(clipPath)
|
||||
}
|
||||
|
||||
canvas.clipPath(clipPath)
|
||||
super.draw(canvas)
|
||||
canvas.restore()
|
||||
} else {
|
||||
super.draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
|
||||
/**
|
||||
* Class which stores information for a Recipient's name color in a group.
|
||||
*/
|
||||
class NameColor(
|
||||
@ColorInt private val lightColor: Int,
|
||||
@ColorInt private val darkColor: Int
|
||||
) {
|
||||
@ColorInt
|
||||
fun getColor(context: Context): Int {
|
||||
return if (ThemeUtil.isDarkTheme(context)) {
|
||||
darkColor
|
||||
} else {
|
||||
lightColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
|
||||
class ChatColorMappingModel(
|
||||
val chatColors: ChatColors,
|
||||
val isSelected: Boolean,
|
||||
val isAuto: Boolean
|
||||
) : MappingModel<ChatColorMappingModel> {
|
||||
|
||||
val isCustom: Boolean = chatColors.id is ChatColors.Id.Custom
|
||||
|
||||
override fun areItemsTheSame(newItem: ChatColorMappingModel): Boolean {
|
||||
return chatColors == newItem.chatColors && isAuto == newItem.isAuto
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: ChatColorMappingModel): Boolean {
|
||||
return areItemsTheSame(newItem) && isSelected == newItem.isSelected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.TypedArray
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.DeliveryStatusView
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerView
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import java.util.Locale
|
||||
|
||||
private val TAG = Log.tag(ChatColorPreviewView::class.java)
|
||||
|
||||
class ChatColorPreviewView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val wallpaper: ImageView
|
||||
private val wallpaperDim: View
|
||||
private val colorizerView: ColorizerView
|
||||
|
||||
private val recv1: Bubble
|
||||
private val sent1: Bubble
|
||||
private val recv2: Bubble
|
||||
private val sent2: Bubble
|
||||
|
||||
private val bubbleCount: Int
|
||||
private val colorizer: Colorizer
|
||||
|
||||
private var chatColors: ChatColors? = null
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.chat_colors_preview_view, this)
|
||||
|
||||
var typedArray: TypedArray? = null
|
||||
try {
|
||||
typedArray = context.obtainStyledAttributes(attrs, R.styleable.ChatColorPreviewView, 0, 0)
|
||||
|
||||
bubbleCount = typedArray.getInteger(R.styleable.ChatColorPreviewView_ccpv_chat_bubble_count, 2)
|
||||
|
||||
assert(bubbleCount == 2 || bubbleCount == 4) {
|
||||
Log.e(TAG, "Bubble count must be 2 or 4")
|
||||
}
|
||||
|
||||
recv1 = Bubble(
|
||||
findViewById(R.id.bubble_1),
|
||||
findViewById(R.id.bubble_1_text),
|
||||
findViewById(R.id.bubble_1_time),
|
||||
null
|
||||
)
|
||||
|
||||
sent1 = Bubble(
|
||||
findViewById(R.id.bubble_2),
|
||||
findViewById(R.id.bubble_2_text),
|
||||
findViewById(R.id.bubble_2_time),
|
||||
findViewById(R.id.bubble_2_delivery)
|
||||
)
|
||||
|
||||
recv2 = Bubble(
|
||||
findViewById(R.id.bubble_3),
|
||||
findViewById(R.id.bubble_3_text),
|
||||
findViewById(R.id.bubble_3_time),
|
||||
null
|
||||
)
|
||||
|
||||
sent2 = Bubble(
|
||||
findViewById(R.id.bubble_4),
|
||||
findViewById(R.id.bubble_4_text),
|
||||
findViewById(R.id.bubble_4_time),
|
||||
findViewById(R.id.bubble_4_delivery)
|
||||
)
|
||||
|
||||
val now: String = DateUtils.getExtendedRelativeTimeSpanString(context, Locale.getDefault(), System.currentTimeMillis())
|
||||
listOf(sent1, sent2, recv1, recv2).forEach {
|
||||
it.time.text = now
|
||||
it.delivery?.setRead()
|
||||
}
|
||||
|
||||
if (bubbleCount == 2) {
|
||||
recv2.bubble.visibility = View.GONE
|
||||
sent2.bubble.visibility = View.GONE
|
||||
}
|
||||
|
||||
wallpaper = findViewById(R.id.wallpaper)
|
||||
wallpaperDim = findViewById(R.id.wallpaper_dim)
|
||||
colorizerView = findViewById(R.id.colorizer)
|
||||
colorizer = Colorizer(colorizerView)
|
||||
} finally {
|
||||
typedArray?.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
if (chatColors != null) {
|
||||
setChatColors(requireNotNull(chatColors))
|
||||
}
|
||||
}
|
||||
|
||||
fun setWallpaper(chatWallpaper: ChatWallpaper?) {
|
||||
if (chatWallpaper != null) {
|
||||
chatWallpaper.loadInto(wallpaper)
|
||||
|
||||
if (ThemeUtil.isDarkTheme(context)) {
|
||||
wallpaperDim.alpha = chatWallpaper.dimLevelForDarkTheme
|
||||
} else {
|
||||
wallpaperDim.alpha = 0f
|
||||
}
|
||||
} else {
|
||||
wallpaper.background = null
|
||||
wallpaperDim.alpha = 0f
|
||||
}
|
||||
|
||||
val backgroundColor = if (chatWallpaper != null) {
|
||||
R.color.conversation_item_wallpaper_bubble_color
|
||||
} else {
|
||||
R.color.signal_background_secondary
|
||||
}
|
||||
|
||||
listOf(recv1, recv2).forEach {
|
||||
it.bubble.background.colorFilter = PorterDuffColorFilter(
|
||||
ContextCompat.getColor(context, backgroundColor),
|
||||
PorterDuff.Mode.SRC_IN
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setChatColors(chatColors: ChatColors) {
|
||||
this.chatColors = chatColors
|
||||
|
||||
val sentBubbles = listOf(sent1, sent2)
|
||||
|
||||
sentBubbles.forEach {
|
||||
it.bubble.background.colorFilter = chatColors.chatBubbleColorFilter
|
||||
}
|
||||
|
||||
val mask: Drawable = chatColors.chatBubbleMask
|
||||
val bubbles = if (bubbleCount == 4) {
|
||||
listOf(sent1, sent2)
|
||||
} else {
|
||||
listOf(sent1)
|
||||
}
|
||||
|
||||
val projections = bubbles.map {
|
||||
Projection.relativeToViewWithCommonRoot(it.bubble, colorizerView, Projection.Corners(ViewUtil.dpToPx(10).toFloat()))
|
||||
}
|
||||
|
||||
colorizerView.setProjections(projections)
|
||||
colorizerView.visibility = View.VISIBLE
|
||||
colorizerView.background = mask
|
||||
|
||||
sentBubbles.forEach {
|
||||
it.body.setTextColor(colorizer.getOutgoingBodyTextColor(context))
|
||||
it.time.setTextColor(colorizer.getOutgoingFooterTextColor(context))
|
||||
it.delivery?.setTint(colorizer.getOutgoingFooterIconColor(context))
|
||||
}
|
||||
}
|
||||
|
||||
private class Bubble(val bubble: View, val body: TextView, val time: TextView, val delivery: DeliveryStatusView?)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Path
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.customizeOnDraw
|
||||
|
||||
class ChatColorSelectionAdapter(
|
||||
context: Context,
|
||||
private val callbacks: Callbacks
|
||||
) : MappingAdapter() {
|
||||
init {
|
||||
val popupWindow = ChatSelectionContextMenu(context)
|
||||
|
||||
registerFactory(
|
||||
ChatColorMappingModel::class.java,
|
||||
LayoutFactory(
|
||||
{ v -> ChatColorMappingViewHolder(v, popupWindow, callbacks) },
|
||||
R.layout.chat_color_selection_adapter_item
|
||||
)
|
||||
)
|
||||
registerFactory(
|
||||
CustomColorMappingModel::class.java,
|
||||
LayoutFactory(
|
||||
{ v -> CustomColorMappingViewHolder(v, callbacks::onAdd) },
|
||||
R.layout.chat_color_custom_adapter_item
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class CustomColorMappingViewHolder(
|
||||
itemView: View,
|
||||
onClicked: () -> Unit
|
||||
) : MappingViewHolder<CustomColorMappingModel>(itemView) {
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener { onClicked() }
|
||||
}
|
||||
|
||||
override fun bind(model: CustomColorMappingModel) = Unit
|
||||
}
|
||||
|
||||
class ChatColorMappingViewHolder(
|
||||
itemView: View,
|
||||
private val popupWindow: ChatSelectionContextMenu,
|
||||
private val callbacks: Callbacks
|
||||
) : MappingViewHolder<ChatColorMappingModel>(itemView) {
|
||||
|
||||
private val preview: ImageView = itemView.findViewById(R.id.chat_color)
|
||||
private val auto: TextView = itemView.findViewById(R.id.auto)
|
||||
private val edit: View = itemView.findViewById(R.id.edit)
|
||||
|
||||
override fun bind(model: ChatColorMappingModel) {
|
||||
itemView.isSelected = model.isSelected
|
||||
|
||||
auto.visibility = if (model.isAuto) View.VISIBLE else View.GONE
|
||||
edit.visibility = if (model.isCustom) View.VISIBLE else View.GONE
|
||||
|
||||
preview.setOnClickListener {
|
||||
if (model.isCustom && model.isSelected) {
|
||||
callbacks.onEdit(model.chatColors)
|
||||
} else {
|
||||
callbacks.onSelect(model.chatColors)
|
||||
}
|
||||
}
|
||||
|
||||
if (model.isCustom) {
|
||||
preview.setOnLongClickListener {
|
||||
popupWindow.callback = CallbackBinder(callbacks, model.chatColors)
|
||||
popupWindow.show(itemView)
|
||||
true
|
||||
}
|
||||
} else {
|
||||
preview.setOnLongClickListener(null)
|
||||
preview.isLongClickable = false
|
||||
}
|
||||
|
||||
val mask = model.chatColors.chatBubbleMask
|
||||
preview.setImageDrawable(
|
||||
mask.customizeOnDraw { wrapped, canvas ->
|
||||
val circlePath = Path()
|
||||
val bounds = canvas.clipBounds
|
||||
circlePath.addCircle(
|
||||
bounds.width() / 2f,
|
||||
bounds.height() / 2f,
|
||||
bounds.width() / 2f,
|
||||
Path.Direction.CW
|
||||
)
|
||||
|
||||
canvas.save()
|
||||
canvas.clipPath(circlePath)
|
||||
wrapped.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class CallbackBinder(private val callbacks: Callbacks, private val chatColors: ChatColors) : ChatSelectionContextMenu.Callback {
|
||||
override fun onEditPressed() {
|
||||
callbacks.onEdit(chatColors)
|
||||
}
|
||||
|
||||
override fun onDuplicatePressed() {
|
||||
callbacks.onDuplicate(chatColors)
|
||||
}
|
||||
|
||||
override fun onDeletePressed() {
|
||||
callbacks.onDelete(chatColors)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun onSelect(chatColors: ChatColors)
|
||||
fun onEdit(chatColors: ChatColors)
|
||||
fun onDuplicate(chatColors: ChatColors)
|
||||
fun onDelete(chatColors: ChatColors)
|
||||
fun onAdd()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
|
||||
class ChatColorSelectionFragment : Fragment(R.layout.chat_color_selection_fragment) {
|
||||
|
||||
private lateinit var viewModel: ChatColorSelectionViewModel
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val args: ChatColorSelectionFragmentArgs = ChatColorSelectionFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
viewModel = ChatColorSelectionViewModel.getOrCreate(requireActivity(), args.recipientId)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
val preview: ChatColorPreviewView = view.findViewById(R.id.preview)
|
||||
val recycler: RecyclerView = view.findViewById(R.id.recycler)
|
||||
val adapter = ChatColorSelectionAdapter(
|
||||
requireContext(),
|
||||
Callbacks(args, view)
|
||||
)
|
||||
|
||||
recycler.itemAnimator = null
|
||||
recycler.adapter = adapter
|
||||
|
||||
toolbar.setNavigationOnClickListener {
|
||||
Navigation.findNavController(it).popBackStack()
|
||||
}
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
preview.setWallpaper(state.wallpaper)
|
||||
|
||||
if (state.chatColors != null) {
|
||||
preview.setChatColors(state.chatColors)
|
||||
}
|
||||
|
||||
adapter.submitList(state.chatColorModels)
|
||||
}
|
||||
|
||||
viewModel.events.observe(viewLifecycleOwner) { event ->
|
||||
if (event is ChatColorSelectionViewModel.Event.ConfirmDeletion) {
|
||||
showWarningDialog(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
private fun showWarningDialog(confirmDeletion: ChatColorSelectionViewModel.Event.ConfirmDeletion) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.ChatColorSelectionFragment__delete_color)
|
||||
.setMessage(resources.getQuantityString(R.plurals.ChatColorSelectionFragment__this_custom_color_is_used, confirmDeletion.usageCount, confirmDeletion.usageCount))
|
||||
.setPositiveButton(R.string.delete) { dialog, _ ->
|
||||
viewModel.deleteNow(confirmDeletion.chatColors)
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
inner class Callbacks(
|
||||
private val args: ChatColorSelectionFragmentArgs,
|
||||
private val view: View
|
||||
) : ChatColorSelectionAdapter.Callbacks {
|
||||
override fun onSelect(chatColors: ChatColors) {
|
||||
viewModel.save(chatColors)
|
||||
}
|
||||
|
||||
override fun onEdit(chatColors: ChatColors) {
|
||||
val startPage = if (chatColors.getColors().size == 1) 0 else 1
|
||||
|
||||
val directions = ChatColorSelectionFragmentDirections
|
||||
.actionChatColorSelectionFragmentToCustomChatColorCreatorFragment(args.recipientId, startPage)
|
||||
.setChatColorId(chatColors.id.longValue)
|
||||
|
||||
Navigation.findNavController(view).navigate(directions)
|
||||
}
|
||||
|
||||
override fun onDuplicate(chatColors: ChatColors) {
|
||||
viewModel.duplicate(chatColors)
|
||||
}
|
||||
|
||||
override fun onDelete(chatColors: ChatColors) {
|
||||
viewModel.startDeletion(chatColors)
|
||||
}
|
||||
|
||||
override fun onAdd() {
|
||||
val directions = ChatColorSelectionFragmentDirections.actionChatColorSelectionFragmentToCustomChatColorCreatorFragment(args.recipientId, 0)
|
||||
Navigation.findNavController(view).navigate(directions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
|
||||
sealed class ChatColorSelectionRepository(context: Context) {
|
||||
|
||||
protected val context: Context = context.applicationContext
|
||||
|
||||
abstract fun getWallpaper(consumer: (ChatWallpaper?) -> Unit)
|
||||
abstract fun getChatColors(consumer: (ChatColors) -> Unit)
|
||||
abstract fun save(chatColors: ChatColors, onSaved: () -> Unit)
|
||||
|
||||
fun duplicate(chatColors: ChatColors) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val duplicate = chatColors.withId(ChatColors.Id.NotSet)
|
||||
DatabaseFactory.getChatColorsDatabase(context).saveChatColors(duplicate)
|
||||
}
|
||||
}
|
||||
|
||||
fun getUsageCount(chatColors: ChatColors, consumer: (Int) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(DatabaseFactory.getRecipientDatabase(context).getColorUsageCount(chatColors))
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(chatColors: ChatColors, onDeleted: () -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
DatabaseFactory.getChatColorsDatabase(context).deleteChatColors(chatColors)
|
||||
onDeleted()
|
||||
}
|
||||
}
|
||||
|
||||
private class Global(context: Context) : ChatColorSelectionRepository(context) {
|
||||
override fun getWallpaper(consumer: (ChatWallpaper?) -> Unit) {
|
||||
consumer(SignalStore.wallpaper().wallpaper)
|
||||
}
|
||||
|
||||
override fun getChatColors(consumer: (ChatColors) -> Unit) {
|
||||
if (SignalStore.chatColorsValues().hasChatColors) {
|
||||
consumer(requireNotNull(SignalStore.chatColorsValues().chatColors))
|
||||
} else {
|
||||
getWallpaper { wallpaper ->
|
||||
if (wallpaper != null) {
|
||||
consumer(wallpaper.autoChatColors)
|
||||
} else {
|
||||
consumer(ChatColorsPalette.Bubbles.default)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun save(chatColors: ChatColors, onSaved: () -> Unit) {
|
||||
if (chatColors.id == ChatColors.Id.Auto) {
|
||||
SignalStore.chatColorsValues().chatColors = null
|
||||
} else {
|
||||
SignalStore.chatColorsValues().chatColors = chatColors
|
||||
}
|
||||
onSaved()
|
||||
}
|
||||
}
|
||||
|
||||
private class Single(context: Context, private val recipientId: RecipientId) : ChatColorSelectionRepository(context) {
|
||||
override fun getWallpaper(consumer: (ChatWallpaper?) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
consumer(recipient.wallpaper)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getChatColors(consumer: (ChatColors) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
consumer(recipient.chatColors)
|
||||
}
|
||||
}
|
||||
|
||||
override fun save(chatColors: ChatColors, onSaved: () -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipientDatabase = DatabaseFactory.getRecipientDatabase(context)
|
||||
recipientDatabase.setColor(recipientId, chatColors)
|
||||
onSaved()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(context: Context, recipientId: RecipientId?): ChatColorSelectionRepository {
|
||||
return if (recipientId != null) {
|
||||
Single(context, recipientId)
|
||||
} else {
|
||||
Global(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.util.MappingModelList
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
|
||||
data class ChatColorSelectionState(
|
||||
val wallpaper: ChatWallpaper? = null,
|
||||
val chatColors: ChatColors? = null,
|
||||
private val chatColorOptions: List<ChatColors> = listOf()
|
||||
) {
|
||||
|
||||
val chatColorModels: MappingModelList
|
||||
|
||||
init {
|
||||
val models: List<ChatColorMappingModel> = chatColorOptions.map { chatColors ->
|
||||
ChatColorMappingModel(
|
||||
chatColors,
|
||||
chatColors == this.chatColors,
|
||||
false
|
||||
)
|
||||
}.toList()
|
||||
|
||||
val defaultModel: ChatColorMappingModel = if (wallpaper != null) {
|
||||
ChatColorMappingModel(
|
||||
wallpaper.autoChatColors,
|
||||
chatColors?.id == ChatColors.Id.Auto,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
ChatColorMappingModel(
|
||||
ChatColorsPalette.Bubbles.default,
|
||||
chatColors?.id == ChatColors.Id.Auto,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
chatColorModels = MappingModelList().apply {
|
||||
add(defaultModel)
|
||||
addAll(models)
|
||||
add(CustomColorMappingModel())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui
|
||||
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class ChatColorSelectionViewModel(private val repository: ChatColorSelectionRepository) : ViewModel() {
|
||||
|
||||
private val store = Store<ChatColorSelectionState>(ChatColorSelectionState())
|
||||
private val chatColors = ChatColorsOptionsLiveData()
|
||||
private val internalEvents = SingleLiveEvent<Event>()
|
||||
|
||||
val state: LiveData<ChatColorSelectionState> = store.stateLiveData
|
||||
val events: LiveData<Event> = internalEvents
|
||||
|
||||
init {
|
||||
store.update(chatColors) { colors, state -> state.copy(chatColorOptions = colors) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
repository.getWallpaper { wallpaper ->
|
||||
store.update { it.copy(wallpaper = wallpaper) }
|
||||
}
|
||||
|
||||
repository.getChatColors { chatColors ->
|
||||
store.update { it.copy(chatColors = chatColors) }
|
||||
}
|
||||
}
|
||||
|
||||
fun save(chatColors: ChatColors) {
|
||||
repository.save(chatColors, this::refresh)
|
||||
}
|
||||
|
||||
fun duplicate(chatColors: ChatColors) {
|
||||
repository.duplicate(chatColors)
|
||||
}
|
||||
|
||||
fun startDeletion(chatColors: ChatColors) {
|
||||
repository.getUsageCount(chatColors) {
|
||||
if (it > 0) {
|
||||
internalEvents.postValue(Event.ConfirmDeletion(it, chatColors))
|
||||
} else {
|
||||
deleteNow(chatColors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNow(chatColors: ChatColors) {
|
||||
repository.delete(chatColors, this::refresh)
|
||||
}
|
||||
|
||||
class Factory(private val repository: ChatColorSelectionRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T = requireNotNull(modelClass.cast(ChatColorSelectionViewModel(repository)))
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getOrCreate(activity: FragmentActivity, recipientId: RecipientId?): ChatColorSelectionViewModel {
|
||||
val repository = ChatColorSelectionRepository.create(activity, recipientId)
|
||||
val viewModelFactory = Factory(repository)
|
||||
|
||||
return ViewModelProviders.of(activity, viewModelFactory).get(ChatColorSelectionViewModel::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Event {
|
||||
class ConfirmDeletion(val usageCount: Int, val chatColors: ChatColors) : Event()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.database.ChatColorsDatabase
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
class ChatColorsOptionsLiveData : LiveData<List<ChatColors>>() {
|
||||
private val chatColorsDatabase: ChatColorsDatabase = DatabaseFactory.getChatColorsDatabase(ApplicationDependencies.getApplication())
|
||||
private val observer: DatabaseObserver.Observer = DatabaseObserver.Observer { refreshChatColors() }
|
||||
private val executor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED)
|
||||
|
||||
override fun onActive() {
|
||||
refreshChatColors()
|
||||
ApplicationDependencies.getDatabaseObserver().registerChatColorsObserver(observer)
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
|
||||
}
|
||||
|
||||
private fun refreshChatColors() {
|
||||
executor.execute {
|
||||
val options = mutableListOf<ChatColors>().apply {
|
||||
addAll(ChatColorsPalette.Bubbles.all)
|
||||
addAll(chatColorsDatabase.getSavedChatColors())
|
||||
}
|
||||
|
||||
postValue(options)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.PopupWindow
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
class ChatSelectionContextMenu(val context: Context) : PopupWindow(context) {
|
||||
|
||||
var callback: Callback? = null
|
||||
|
||||
init {
|
||||
contentView = LayoutInflater.from(context).inflate(R.layout.chat_colors_fragment_context_menu, null, false)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
elevation = ViewUtil.dpToPx(8).toFloat()
|
||||
}
|
||||
|
||||
isOutsideTouchable = false
|
||||
isFocusable = true
|
||||
|
||||
width = ViewUtil.dpToPx(280)
|
||||
|
||||
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.round_background))
|
||||
|
||||
val edit: View = contentView.findViewById(R.id.context_menu_edit)
|
||||
val duplicate: View = contentView.findViewById(R.id.context_menu_duplicate)
|
||||
val delete: View = contentView.findViewById(R.id.context_menu_delete)
|
||||
|
||||
edit.setOnClickListener {
|
||||
dismiss()
|
||||
callback?.onEditPressed()
|
||||
}
|
||||
|
||||
duplicate.setOnClickListener {
|
||||
dismiss()
|
||||
callback?.onDuplicatePressed()
|
||||
}
|
||||
|
||||
delete.setOnClickListener {
|
||||
dismiss()
|
||||
callback?.onDeletePressed()
|
||||
}
|
||||
}
|
||||
|
||||
fun show(anchor: View) {
|
||||
val rect = Rect()
|
||||
val root: ViewGroup = anchor.rootView as ViewGroup
|
||||
|
||||
anchor.getDrawingRect(rect)
|
||||
root.offsetDescendantRectToMyCoords(anchor, rect)
|
||||
|
||||
if (rect.bottom + contentView.height > root.bottom) {
|
||||
showAsDropDown(anchor, 0, -(contentView.height + anchor.height))
|
||||
} else {
|
||||
showAsDropDown(anchor, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onEditPressed()
|
||||
fun onDuplicatePressed()
|
||||
fun onDeletePressed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui
|
||||
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
|
||||
class CustomColorMappingModel : MappingModel<CustomColorMappingModel> {
|
||||
override fun areItemsTheSame(newItem: CustomColorMappingModel): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: CustomColorMappingModel): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui.custom
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class CustomChatColorCreatorFragment : Fragment(R.layout.custom_chat_color_creator_fragment) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
val tabLayout: TabLayout = view.findViewById(R.id.tab_layout)
|
||||
val pager: ViewPager2 = view.findViewById(R.id.pager)
|
||||
val adapter = CustomChatColorPagerAdapter(this, requireArguments())
|
||||
val tabLayoutMediator = TabLayoutMediator(tabLayout, pager) { tab, position ->
|
||||
tab.setText(
|
||||
if (position == 0) {
|
||||
R.string.CustomChatColorCreatorFragment__solid
|
||||
} else {
|
||||
R.string.CustomChatColorCreatorFragment__gradient
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener {
|
||||
Navigation.findNavController(it).popBackStack()
|
||||
}
|
||||
|
||||
pager.isUserInputEnabled = false
|
||||
pager.adapter = adapter
|
||||
tabLayoutMediator.attach()
|
||||
|
||||
val startPage = CustomChatColorCreatorFragmentArgs.fromBundle(requireArguments()).startPage
|
||||
pager.setCurrentItem(startPage, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui.custom
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.PointF
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.SeekBar
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Dimension
|
||||
import androidx.appcompat.widget.AppCompatSeekBar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.navigation.Navigation
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ui.ChatColorPreviewView
|
||||
import org.thoughtcrime.securesms.conversation.colors.ui.ChatColorSelectionViewModel
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.customizeOnDraw
|
||||
|
||||
private const val MAX_SEEK_DIVISIONS = 1023
|
||||
private const val MAX_HUE = 360
|
||||
|
||||
private const val PAGE_ARG = "page"
|
||||
private const val SINGLE_PAGE = 0
|
||||
private const val GRADIENT_PAGE = 1
|
||||
|
||||
class CustomChatColorCreatorPageFragment :
|
||||
Fragment(R.layout.custom_chat_color_creator_fragment_page) {
|
||||
|
||||
private lateinit var hueSlider: AppCompatSeekBar
|
||||
private lateinit var saturationSlider: AppCompatSeekBar
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val args: CustomChatColorCreatorFragmentArgs = CustomChatColorCreatorFragmentArgs.fromBundle(requireArguments())
|
||||
val chatColorSelectionViewModel: ChatColorSelectionViewModel = ChatColorSelectionViewModel.getOrCreate(requireActivity(), args.recipientId)
|
||||
val page: Int = requireArguments().getInt(PAGE_ARG)
|
||||
val factory: CustomChatColorCreatorViewModel.Factory = CustomChatColorCreatorViewModel.Factory(MAX_SEEK_DIVISIONS, ChatColors.Id.forLongValue(args.chatColorId), args.recipientId, createRepository())
|
||||
val viewModel: CustomChatColorCreatorViewModel = ViewModelProviders.of(
|
||||
requireParentFragment(),
|
||||
factory
|
||||
)[CustomChatColorCreatorViewModel::class.java]
|
||||
|
||||
val preview: ChatColorPreviewView = view.findViewById(R.id.chat_color_preview)
|
||||
val hueThumb = ThumbDrawable(requireContext())
|
||||
val saturationThumb = ThumbDrawable(requireContext())
|
||||
|
||||
val gradientTool: CustomChatColorGradientToolView = view.findViewById(R.id.gradient_tool)
|
||||
val save: View = view.findViewById(R.id.save)
|
||||
|
||||
if (page == SINGLE_PAGE) {
|
||||
gradientTool.visibility = View.GONE
|
||||
} else {
|
||||
gradientTool.setListener(object : CustomChatColorGradientToolView.Listener {
|
||||
override fun onDegreesChanged(degrees: Float) {
|
||||
viewModel.setDegrees(degrees)
|
||||
}
|
||||
|
||||
override fun onSelectedEdgeChanged(edge: CustomChatColorEdge) {
|
||||
viewModel.setSelectedEdge(edge)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
hueSlider = view.findViewById(R.id.hue_slider)
|
||||
saturationSlider = view.findViewById(R.id.saturation_slider)
|
||||
|
||||
hueSlider.thumb = hueThumb
|
||||
saturationSlider.thumb = saturationThumb
|
||||
|
||||
hueSlider.max = MAX_SEEK_DIVISIONS
|
||||
saturationSlider.max = MAX_SEEK_DIVISIONS
|
||||
|
||||
val colors: IntArray = (0..MAX_SEEK_DIVISIONS).map { hue ->
|
||||
ColorUtils.HSLToColor(
|
||||
floatArrayOf(
|
||||
hue.toHue(MAX_SEEK_DIVISIONS),
|
||||
1f,
|
||||
calculateLightness(hue.toFloat(), valueFor60To80 = 0.4f)
|
||||
)
|
||||
)
|
||||
}.toIntArray()
|
||||
|
||||
val hueGradientDrawable = GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, colors)
|
||||
|
||||
hueSlider.progressDrawable = hueGradientDrawable.forSeekBar()
|
||||
|
||||
val saturationProgressDrawable = GradientDrawable().apply {
|
||||
orientation = GradientDrawable.Orientation.LEFT_RIGHT
|
||||
}
|
||||
|
||||
saturationSlider.progressDrawable = saturationProgressDrawable.forSeekBar()
|
||||
|
||||
hueSlider.setOnSeekBarChangeListener(
|
||||
OnProgressChangedListener {
|
||||
viewModel.setHueProgress(it)
|
||||
}
|
||||
)
|
||||
|
||||
saturationSlider.setOnSeekBarChangeListener(
|
||||
OnProgressChangedListener {
|
||||
viewModel.setSaturationProgress(it)
|
||||
}
|
||||
)
|
||||
|
||||
viewModel.events.observe(viewLifecycleOwner) { event ->
|
||||
when (event) {
|
||||
is CustomChatColorCreatorViewModel.Event.SaveNow -> {
|
||||
viewModel.saveNow(event.chatColors) { colors ->
|
||||
chatColorSelectionViewModel.save(colors)
|
||||
}
|
||||
Navigation.findNavController(requireParentFragment().requireView()).popBackStack()
|
||||
}
|
||||
is CustomChatColorCreatorViewModel.Event.Warn -> MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.CustomChatColorCreatorFragmentPage__edit_color)
|
||||
.setMessage(resources.getQuantityString(R.plurals.CustomChatColorCreatorFragmentPage__this_color_is_used, event.usageCount, event.usageCount))
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
viewModel.saveNow(event.chatColors) { colors ->
|
||||
chatColorSelectionViewModel.save(colors)
|
||||
}
|
||||
Navigation.findNavController(requireParentFragment().requireView()).popBackStack()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
if (state.loading) {
|
||||
return@observe
|
||||
}
|
||||
|
||||
val sliderState: ColorSlidersState = requireNotNull(state.sliderStates[state.selectedEdge])
|
||||
|
||||
hueSlider.progress = sliderState.huePosition
|
||||
saturationSlider.progress = sliderState.saturationPosition
|
||||
|
||||
val color: Int = sliderState.getColor()
|
||||
hueThumb.setColor(sliderState.getHueColor())
|
||||
saturationThumb.setColor(color)
|
||||
saturationProgressDrawable.colors = sliderState.getSaturationColors()
|
||||
|
||||
preview.setWallpaper(state.wallpaper)
|
||||
|
||||
if (page == 0) {
|
||||
val chatColor = ChatColors.forColor(ChatColors.Id.NotSet, color)
|
||||
preview.setChatColors(chatColor)
|
||||
save.setOnClickListener {
|
||||
viewModel.startSave(chatColor)
|
||||
}
|
||||
} else {
|
||||
val topEdgeColor: ColorSlidersState = requireNotNull(state.sliderStates[CustomChatColorEdge.TOP])
|
||||
val bottomEdgeColor: ColorSlidersState = requireNotNull(state.sliderStates[CustomChatColorEdge.BOTTOM])
|
||||
val chatColor: ChatColors = ChatColors.forGradient(
|
||||
ChatColors.Id.NotSet,
|
||||
ChatColors.LinearGradient(
|
||||
state.degrees,
|
||||
intArrayOf(topEdgeColor.getColor(), bottomEdgeColor.getColor()),
|
||||
floatArrayOf(0f, 1f)
|
||||
),
|
||||
)
|
||||
preview.setChatColors(chatColor)
|
||||
gradientTool.setSelectedEdge(state.selectedEdge)
|
||||
gradientTool.setDegrees(state.degrees)
|
||||
gradientTool.setTopColor(topEdgeColor.getColor())
|
||||
gradientTool.setBottomColor(bottomEdgeColor.getColor())
|
||||
save.setOnClickListener {
|
||||
viewModel.startSave(chatColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRepository(): CustomChatColorCreatorRepository {
|
||||
return CustomChatColorCreatorRepository(requireContext())
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
private fun ColorSlidersState.getHueColor(): Int {
|
||||
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
|
||||
return ColorUtils.HSLToColor(
|
||||
floatArrayOf(
|
||||
hue,
|
||||
1f,
|
||||
calculateLightness(hue, 0.4f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
private fun ColorSlidersState.getColor(): Int {
|
||||
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
|
||||
return ColorUtils.HSLToColor(
|
||||
floatArrayOf(
|
||||
hue,
|
||||
saturationPosition.toUnit(MAX_SEEK_DIVISIONS),
|
||||
calculateLightness(hue)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ColorSlidersState.getSaturationColors(): IntArray {
|
||||
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
|
||||
val level = calculateLightness(hue)
|
||||
|
||||
return listOf(0f, 1f).map {
|
||||
ColorUtils.HSLToColor(
|
||||
floatArrayOf(
|
||||
hue, it, level
|
||||
)
|
||||
)
|
||||
}.toIntArray()
|
||||
}
|
||||
|
||||
private fun calculateLightness(hue: Float, valueFor60To80: Float = 0.3f): Float {
|
||||
|
||||
val point1 = PointF()
|
||||
val point2 = PointF()
|
||||
|
||||
if (hue >= 0f && hue < 60f) {
|
||||
point1.set(0f, 0.45f)
|
||||
point2.set(60f, valueFor60To80)
|
||||
} else if (hue >= 60f && hue < 180f) {
|
||||
return valueFor60To80
|
||||
} else if (hue >= 180f && hue < 240f) {
|
||||
point1.set(180f, valueFor60To80)
|
||||
point2.set(240f, 0.5f)
|
||||
} else if (hue >= 240f && hue < 300f) {
|
||||
point1.set(240f, 0.5f)
|
||||
point2.set(300f, 0.4f)
|
||||
} else if (hue >= 300f && hue < 360f) {
|
||||
point1.set(300f, 0.4f)
|
||||
point2.set(360f, 0.45f)
|
||||
} else {
|
||||
return 0.45f
|
||||
}
|
||||
|
||||
return interpolate(point1, point2, hue)
|
||||
}
|
||||
|
||||
private fun interpolate(point1: PointF, point2: PointF, x: Float): Float {
|
||||
return ((point1.y * (point2.x - x)) + (point2.y * (x - point1.x))) / (point2.x - point1.x)
|
||||
}
|
||||
|
||||
private fun Number.toHue(max: Number): Float {
|
||||
return Util.clamp(toFloat() * (MAX_HUE / max.toFloat()), 0f, MAX_HUE.toFloat())
|
||||
}
|
||||
|
||||
private fun Number.toUnit(max: Number): Float {
|
||||
return Util.clamp(toFloat() / max.toFloat(), 0f, 1f)
|
||||
}
|
||||
|
||||
private fun Drawable.forSeekBar(): Drawable {
|
||||
val height: Int = ViewUtil.dpToPx(8)
|
||||
val radii: FloatArray = (1..8).map { 50f }.toFloatArray()
|
||||
val bounds = RectF()
|
||||
val clipPath = Path()
|
||||
|
||||
return customizeOnDraw { wrapped, canvas ->
|
||||
canvas.save()
|
||||
bounds.set(this.bounds)
|
||||
bounds.inset(0f, (height / 2f) + 1)
|
||||
|
||||
clipPath.rewind()
|
||||
clipPath.addRoundRect(bounds, radii, Path.Direction.CW)
|
||||
|
||||
canvas.clipPath(clipPath)
|
||||
wrapped.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
||||
private class OnProgressChangedListener(private val updateFn: (Int) -> Unit) :
|
||||
SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
updateFn(progress)
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
|
||||
}
|
||||
|
||||
private class ThumbDrawable(context: Context) : Drawable() {
|
||||
|
||||
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(context, R.color.signal_background_primary)
|
||||
}
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.TRANSPARENT
|
||||
}
|
||||
|
||||
private val borderWidth: Int = ViewUtil.dpToPx(THUMB_MARGIN)
|
||||
private val thumbInnerSize: Int = ViewUtil.dpToPx(THUMB_INNER_SIZE)
|
||||
private val innerRadius: Float = thumbInnerSize / 2f
|
||||
private val thumbSize: Float = (thumbInnerSize + borderWidth).toFloat()
|
||||
private val thumbRadius: Float = thumbSize / 2f
|
||||
|
||||
override fun getIntrinsicHeight(): Int = ViewUtil.dpToPx(48)
|
||||
|
||||
override fun getIntrinsicWidth(): Int = ViewUtil.dpToPx(48)
|
||||
|
||||
fun setColor(@ColorInt color: Int) {
|
||||
paint.color = color
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
canvas.drawCircle(
|
||||
(bounds.width() / 2f) + bounds.left,
|
||||
(bounds.height() / 2f) + bounds.top,
|
||||
thumbRadius,
|
||||
borderPaint
|
||||
)
|
||||
canvas.drawCircle(
|
||||
(bounds.width() / 2f) + bounds.left,
|
||||
(bounds.height() / 2f) + bounds.top,
|
||||
innerRadius,
|
||||
paint
|
||||
)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) = Unit
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
|
||||
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSPARENT
|
||||
|
||||
companion object {
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private val THUMB_INNER_SIZE = 16
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private val THUMB_MARGIN = 1
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun forSingle(bundle: Bundle): Fragment = forPage(SINGLE_PAGE, bundle)
|
||||
|
||||
fun forGradient(bundle: Bundle): Fragment = forPage(GRADIENT_PAGE, bundle)
|
||||
|
||||
private fun forPage(page: Int, bundle: Bundle): Fragment = CustomChatColorCreatorPageFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putInt(PAGE_ARG, page)
|
||||
putAll(bundle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui.custom
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
|
||||
class CustomChatColorCreatorRepository(private val context: Context) {
|
||||
fun loadColors(chatColorsId: ChatColors.Id, consumer: (ChatColors) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val chatColorsDatabase = DatabaseFactory.getChatColorsDatabase(context)
|
||||
val chatColors = chatColorsDatabase.getById(chatColorsId)
|
||||
|
||||
consumer(chatColors)
|
||||
}
|
||||
}
|
||||
|
||||
fun getWallpaper(recipientId: RecipientId?, consumer: (ChatWallpaper?) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
if (recipientId != null) {
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
consumer(recipient.wallpaper)
|
||||
} else {
|
||||
consumer(SignalStore.wallpaper().wallpaper)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setChatColors(chatColors: ChatColors, consumer: (ChatColors) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val chatColorsDatabase = DatabaseFactory.getChatColorsDatabase(context)
|
||||
val savedColors = chatColorsDatabase.saveChatColors(chatColors)
|
||||
|
||||
consumer(savedColors)
|
||||
}
|
||||
}
|
||||
|
||||
fun getUsageCount(chatColors: ChatColors, consumer: (Int) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipientsDatabase = DatabaseFactory.getRecipientDatabase(context)
|
||||
|
||||
consumer(recipientsDatabase.getColorUsageCount(chatColors))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui.custom
|
||||
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import java.util.EnumMap
|
||||
|
||||
data class CustomChatColorCreatorState(
|
||||
val loading: Boolean,
|
||||
val wallpaper: ChatWallpaper?,
|
||||
val sliderStates: EnumMap<CustomChatColorEdge, ColorSlidersState>,
|
||||
val selectedEdge: CustomChatColorEdge,
|
||||
val degrees: Float
|
||||
)
|
||||
|
||||
data class ColorSlidersState(val huePosition: Int, val saturationPosition: Int)
|
||||
@@ -0,0 +1,153 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui.custom
|
||||
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.EnumMap
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class CustomChatColorCreatorViewModel(
|
||||
private val maxSliderValue: Int,
|
||||
private val chatColorsId: ChatColors.Id,
|
||||
private val recipientId: RecipientId?,
|
||||
private val repository: CustomChatColorCreatorRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store<CustomChatColorCreatorState>(getInitialState())
|
||||
private val internalEvents = SingleLiveEvent<Event>()
|
||||
|
||||
val state: LiveData<CustomChatColorCreatorState> = store.stateLiveData
|
||||
val events: LiveData<Event> = internalEvents
|
||||
|
||||
init {
|
||||
repository.getWallpaper(recipientId) { wallpaper ->
|
||||
store.update { it.copy(wallpaper = wallpaper) }
|
||||
}
|
||||
|
||||
if (chatColorsId is ChatColors.Id.Custom) {
|
||||
repository.loadColors(chatColorsId) {
|
||||
val colors: IntArray = it.getColors()
|
||||
|
||||
val topColor: Int = colors.first()
|
||||
val bottomColor: Int = colors.last()
|
||||
|
||||
val topHsl = floatArrayOf(0f, 0f, 0f)
|
||||
val bottomHsl = floatArrayOf(0f, 0f, 0f)
|
||||
|
||||
ColorUtils.colorToHSL(topColor, topHsl)
|
||||
ColorUtils.colorToHSL(bottomColor, bottomHsl)
|
||||
|
||||
val topHue: Float = topHsl[0]
|
||||
val topSaturation: Float = topHsl[1]
|
||||
|
||||
val bottomHue: Float = bottomHsl[0]
|
||||
val bottomSaturation: Float = bottomHsl[1]
|
||||
|
||||
val topEdge = ColorSlidersState(
|
||||
huePosition = ((topHue / 360f) * maxSliderValue).roundToInt(),
|
||||
saturationPosition = (topSaturation * maxSliderValue).roundToInt()
|
||||
)
|
||||
|
||||
val bottomEdge = ColorSlidersState(
|
||||
huePosition = ((bottomHue / 360f) * maxSliderValue).roundToInt(),
|
||||
saturationPosition = (bottomSaturation * maxSliderValue).roundToInt()
|
||||
)
|
||||
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
degrees = it.getDegrees(),
|
||||
loading = false,
|
||||
sliderStates = EnumMap(
|
||||
mapOf(
|
||||
CustomChatColorEdge.TOP to topEdge,
|
||||
CustomChatColorEdge.BOTTOM to bottomEdge
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setHueProgress(progress: Int) {
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
sliderStates = state.sliderStates.apply {
|
||||
val oldData: ColorSlidersState = requireNotNull(get(state.selectedEdge))
|
||||
put(state.selectedEdge, oldData.copy(huePosition = progress))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSaturationProgress(progress: Int) {
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
sliderStates = state.sliderStates.apply {
|
||||
val oldData: ColorSlidersState = requireNotNull(get(state.selectedEdge))
|
||||
put(state.selectedEdge, oldData.copy(saturationPosition = progress))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDegrees(degrees: Float) {
|
||||
store.update { it.copy(degrees = degrees) }
|
||||
}
|
||||
|
||||
fun setSelectedEdge(selectedEdge: CustomChatColorEdge) {
|
||||
store.update { it.copy(selectedEdge = selectedEdge) }
|
||||
}
|
||||
|
||||
fun startSave(chatColors: ChatColors) {
|
||||
if (chatColors.id is ChatColors.Id.Custom) {
|
||||
repository.getUsageCount(chatColors) {
|
||||
if (it > 0) {
|
||||
internalEvents.postValue(Event.Warn(it, chatColors))
|
||||
} else {
|
||||
internalEvents.postValue(Event.SaveNow(chatColors))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
internalEvents.postValue(Event.SaveNow(chatColors))
|
||||
}
|
||||
}
|
||||
|
||||
fun saveNow(chatColors: ChatColors, onSaved: (ChatColors) -> Unit) {
|
||||
repository.setChatColors(chatColors.withId(chatColorsId), onSaved)
|
||||
}
|
||||
|
||||
private fun getInitialState() = CustomChatColorCreatorState(
|
||||
loading = chatColorsId is ChatColors.Id.Custom,
|
||||
wallpaper = null,
|
||||
sliderStates = EnumMap(
|
||||
mapOf(
|
||||
CustomChatColorEdge.TOP to ColorSlidersState(maxSliderValue / 2, maxSliderValue / 2),
|
||||
CustomChatColorEdge.BOTTOM to ColorSlidersState(maxSliderValue / 2, maxSliderValue / 2)
|
||||
)
|
||||
),
|
||||
selectedEdge = CustomChatColorEdge.BOTTOM,
|
||||
degrees = 180f
|
||||
)
|
||||
|
||||
class Factory(
|
||||
private val maxSliderValue: Int,
|
||||
private val chatColorsId: ChatColors.Id,
|
||||
private val recipientId: RecipientId?,
|
||||
private val chatColorCreatorRepository: CustomChatColorCreatorRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(CustomChatColorCreatorViewModel(maxSliderValue, chatColorsId, recipientId, chatColorCreatorRepository)))
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Event {
|
||||
class Warn(val usageCount: Int, val chatColors: ChatColors) : Event()
|
||||
class SaveNow(val chatColors: ChatColors) : Event()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui.custom
|
||||
|
||||
enum class CustomChatColorEdge {
|
||||
TOP, BOTTOM
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui.custom
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PointF
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Dimension
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.GestureDetectorCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.tan
|
||||
|
||||
/**
|
||||
* Renders the gradient customization tool.
|
||||
*
|
||||
* The Gradient customization tool is two selectable circles on either side
|
||||
* of a rectangle with a pipe connecting them, a TOP and a BOTTOM (an edge)
|
||||
*
|
||||
* The user can then swap between the selected edge via a touch-down and can
|
||||
* drag the selected edge such that it traces around the outline of the square.
|
||||
* The other edge traces along the opposite side of the rectangle.
|
||||
*
|
||||
* The way the position along the edge is determined is by dividing the rectangle
|
||||
* into 8 right-angled triangles, all joining at the center. Using the specified
|
||||
* angle, we can determine which "octant" the top edge should be in, and can
|
||||
* determine its distance from the center point of the relevant edge, and use
|
||||
* similar logic to determine where the bottom edge lies.
|
||||
*
|
||||
* All of the math assumes an origin at the dead center of the view, and
|
||||
* that 0deg corresponds to a vector pointing directly towards the right hand
|
||||
* side of the view. This doesn't quite line up with what the gradient rendering
|
||||
* math requires, so we apply a simple function to degrees when it comes into or
|
||||
* leaves this tool (see `Float.invert`)
|
||||
*/
|
||||
class CustomChatColorGradientToolView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val clipRect = Rect()
|
||||
private val rect = RectF()
|
||||
private val center = PointF()
|
||||
private val top = PointF()
|
||||
private val bottom = PointF()
|
||||
|
||||
private var selectedEdge: CustomChatColorEdge = CustomChatColorEdge.TOP
|
||||
private var degrees: Float = 18f
|
||||
private var listener: Listener? = null
|
||||
|
||||
private val thumbRadius: Float = ViewUtil.dpToPx(THUMB_RADIUS).toFloat()
|
||||
private val thumbBorder: Float = ViewUtil.dpToPx(THUMB_BORDER).toFloat()
|
||||
private val thumbBorderSelected: Float = ViewUtil.dpToPx(THUMB_BORDER_SELECTED).toFloat()
|
||||
private val opaqueThumbRadius: Float = ViewUtil.dpToPx(OPAQUE_THUMB_RADIUS).toFloat()
|
||||
private val opaqueThumbPadding: Float = ViewUtil.dpToPx(OPAGUE_THUMB_PADDING).toFloat()
|
||||
private val opaqueThumbPaddingSelected: Float = ViewUtil.dpToPx(OPAGUE_THUMB_PADDING_SELECTED).toFloat()
|
||||
private val pipeWidth: Float = ViewUtil.dpToPx(PIPE_WIDTH).toFloat()
|
||||
private val pipeBorder: Float = ViewUtil.dpToPx(PIPE_BORDER).toFloat()
|
||||
|
||||
private val topColorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.RED
|
||||
}
|
||||
|
||||
private val bottomColorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.BLUE
|
||||
}
|
||||
|
||||
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(context, R.color.signal_background_primary)
|
||||
}
|
||||
|
||||
private val thumbBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_10)
|
||||
}
|
||||
|
||||
private val thumbBorderPaintSelected = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_60)
|
||||
}
|
||||
|
||||
private val pipePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = pipeWidth - pipeBorder * 2
|
||||
style = Paint.Style.STROKE
|
||||
color = ContextCompat.getColor(context, R.color.signal_background_primary)
|
||||
}
|
||||
|
||||
private val pipeBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = pipeWidth
|
||||
style = Paint.Style.STROKE
|
||||
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_10)
|
||||
}
|
||||
|
||||
val gestureDetectorCompat = GestureDetectorCompat(context, GestureListener())
|
||||
|
||||
fun setTopColor(@ColorInt color: Int) {
|
||||
topColorPaint.color = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setBottomColor(@ColorInt color: Int) {
|
||||
bottomColorPaint.color = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setSelectedEdge(selectedEdge: CustomChatColorEdge) {
|
||||
if (this.selectedEdge == selectedEdge) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedEdge = selectedEdge
|
||||
invalidate()
|
||||
|
||||
listener?.onSelectedEdgeChanged(selectedEdge)
|
||||
}
|
||||
|
||||
fun setDegrees(degrees: Float) {
|
||||
setDegreesInternal(degrees.invertDegrees())
|
||||
}
|
||||
|
||||
private fun setDegreesInternal(degrees: Float) {
|
||||
if (this.degrees == degrees) {
|
||||
return
|
||||
}
|
||||
|
||||
this.degrees = degrees
|
||||
invalidate()
|
||||
|
||||
listener?.onDegreesChanged(degrees.invertDegrees())
|
||||
}
|
||||
|
||||
private fun Float.invertDegrees(): Float = 360f - rotate(90f)
|
||||
|
||||
fun setListener(listener: Listener) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent?): Boolean {
|
||||
return gestureDetectorCompat.onTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
canvas.getClipBounds(clipRect)
|
||||
rect.set(clipRect)
|
||||
rect.inset(thumbRadius, thumbRadius)
|
||||
|
||||
center.set(rect.width() / 2f, rect.height() / 2f)
|
||||
|
||||
val alpha = atan((rect.height() / rect.width())).toDegrees()
|
||||
val beta = (360.0 - alpha * 4) / 4f
|
||||
|
||||
if (degrees < alpha) {
|
||||
// Right top
|
||||
val a = center.x
|
||||
val b = a * tan(degrees.toRadians())
|
||||
top.set(rect.width(), center.y - b)
|
||||
bottom.set(0f, center.y + b)
|
||||
} else if (degrees < 90f) {
|
||||
// Top right
|
||||
val phi = 90f - degrees
|
||||
val a = center.y
|
||||
val b = a * tan(phi.toRadians())
|
||||
top.set(center.x + b, 0f)
|
||||
bottom.set(center.x - b, rect.height())
|
||||
} else if (degrees < (90f + beta)) {
|
||||
// Top left
|
||||
val phi = degrees - 90f
|
||||
val a = center.y
|
||||
val b = a * tan(phi.toRadians())
|
||||
top.set(center.x - b, 0f)
|
||||
bottom.set(center.x + b, rect.height())
|
||||
} else if (degrees < 180f) {
|
||||
// left top
|
||||
val phi = 180f - degrees
|
||||
val a = center.x
|
||||
val b = a * tan(phi.toRadians())
|
||||
top.set(0f, center.y - b)
|
||||
bottom.set(rect.width(), center.y + b)
|
||||
} else if (degrees < (180f + alpha)) {
|
||||
// left bottom
|
||||
val phi = degrees - 180f
|
||||
val a = center.x
|
||||
val b = a * tan(phi.toRadians())
|
||||
top.set(0f, center.y + b)
|
||||
bottom.set(rect.width(), center.y - b)
|
||||
} else if (degrees < 270f) {
|
||||
// bottom left
|
||||
val phi = 270f - degrees
|
||||
val a = center.y
|
||||
val b = a * tan(phi.toRadians())
|
||||
top.set(center.x - b, rect.height())
|
||||
bottom.set(center.x + b, 0f)
|
||||
} else if (degrees < (270f + beta)) {
|
||||
// bottom right
|
||||
val phi = degrees - 270f
|
||||
val a = center.y
|
||||
val b = a * tan(phi.toRadians())
|
||||
top.set(center.x + b, rect.height())
|
||||
bottom.set(center.x - b, 0f)
|
||||
} else {
|
||||
// right bottom
|
||||
val phi = 360f - degrees
|
||||
val a = center.x
|
||||
val b = a * tan(phi.toRadians())
|
||||
top.set(rect.width(), center.y + b)
|
||||
bottom.set(0f, center.y - b)
|
||||
}
|
||||
|
||||
val (selected, other) = when (selectedEdge) {
|
||||
CustomChatColorEdge.TOP -> top to bottom
|
||||
CustomChatColorEdge.BOTTOM -> bottom to top
|
||||
}
|
||||
|
||||
val (selectedPaint, otherPaint) = when (selectedEdge) {
|
||||
CustomChatColorEdge.TOP -> topColorPaint to bottomColorPaint
|
||||
CustomChatColorEdge.BOTTOM -> bottomColorPaint to topColorPaint
|
||||
}
|
||||
|
||||
canvas.apply {
|
||||
save()
|
||||
translate(rect.top, rect.left)
|
||||
|
||||
drawLine(selected.x, selected.y, other.x, other.y, pipeBorderPaint)
|
||||
drawLine(selected.x, selected.y, other.x, other.y, pipePaint)
|
||||
|
||||
drawCircle(other.x, other.y, opaqueThumbRadius + thumbBorder, thumbBorderPaint)
|
||||
drawCircle(other.x, other.y, opaqueThumbRadius, backgroundPaint)
|
||||
drawCircle(other.x, other.y, opaqueThumbRadius - opaqueThumbPadding, otherPaint)
|
||||
|
||||
drawCircle(selected.x, selected.y, opaqueThumbRadius + thumbBorderSelected, thumbBorderPaintSelected)
|
||||
drawCircle(selected.x, selected.y, opaqueThumbRadius, backgroundPaint)
|
||||
drawCircle(selected.x, selected.y, opaqueThumbRadius - opaqueThumbPaddingSelected, selectedPaint)
|
||||
restore()
|
||||
}
|
||||
|
||||
top.offset(rect.top, rect.left)
|
||||
bottom.offset(rect.top, rect.left)
|
||||
}
|
||||
|
||||
private fun Float.toDegrees(): Float = this * (180f / Math.PI.toFloat())
|
||||
private fun Float.toRadians(): Float = this * (Math.PI.toFloat() / 180f)
|
||||
private fun PointF.distance(other: PointF): Float = abs(sqrt((this.x - other.x).pow(2) + (this.y - other.y).pow(2)))
|
||||
private fun PointF.dotProduct(other: PointF): Float = (this.x * other.x) + (this.y * other.y)
|
||||
private fun PointF.determinate(other: PointF): Float = (this.x * other.y) - (this.y * other.x)
|
||||
|
||||
private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
|
||||
|
||||
var activePointerId: Int = MotionEvent.INVALID_POINTER_ID
|
||||
|
||||
override fun onDown(e: MotionEvent): Boolean {
|
||||
activePointerId = e.getPointerId(0)
|
||||
|
||||
val touchPoint = PointF(e.getX(activePointerId), e.getY(activePointerId))
|
||||
val distanceFromTop = touchPoint.distance(top)
|
||||
|
||||
if (distanceFromTop <= thumbRadius) {
|
||||
setSelectedEdge(CustomChatColorEdge.TOP)
|
||||
return true
|
||||
}
|
||||
|
||||
val distanceFromBottom = touchPoint.distance(bottom)
|
||||
if (distanceFromBottom <= thumbRadius) {
|
||||
setSelectedEdge(CustomChatColorEdge.BOTTOM)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onScroll(
|
||||
e1: MotionEvent,
|
||||
e2: MotionEvent,
|
||||
distanceX: Float,
|
||||
distanceY: Float
|
||||
): Boolean {
|
||||
|
||||
val a = PointF(e2.getX(activePointerId) - center.x, e2.getY(activePointerId) - center.y)
|
||||
val b = PointF(center.x, 0f)
|
||||
val dot = a.dotProduct(b)
|
||||
val det = a.determinate(b)
|
||||
|
||||
val offset = if (selectedEdge == CustomChatColorEdge.BOTTOM) 180f else 0f
|
||||
val degrees = (atan2(det, dot).toDegrees() + 360f + offset) % 360f
|
||||
|
||||
setDegreesInternal(degrees)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.rotate(degrees: Float): Float = (this + degrees + 360f) % 360f
|
||||
|
||||
interface Listener {
|
||||
fun onDegreesChanged(degrees: Float)
|
||||
fun onSelectedEdgeChanged(edge: CustomChatColorEdge)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private const val THUMB_RADIUS = 24
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private const val THUMB_BORDER = 1
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private const val THUMB_BORDER_SELECTED = 4
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private const val OPAQUE_THUMB_RADIUS = 20
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private const val OPAGUE_THUMB_PADDING = 2
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private const val OPAGUE_THUMB_PADDING_SELECTED = 1
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private const val PIPE_WIDTH = 6
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private const val PIPE_BORDER = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui.custom
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
||||
class CustomChatColorPagerAdapter(parentFragment: Fragment, private val arguments: Bundle) : FragmentStateAdapter(parentFragment) {
|
||||
override fun getItemCount(): Int = 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
0 -> CustomChatColorCreatorPageFragment.forSingle(arguments)
|
||||
1 -> CustomChatColorCreatorPageFragment.forGradient(arguments)
|
||||
else -> {
|
||||
throw AssertionError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user