From 4bfcf209d7aa996ecfab4e88f9137620aa533e5e Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Mon, 27 Mar 2023 16:48:35 +0200 Subject: [PATCH] add date separators --- .../database/model/MessageAdapterItem.java | 15 +++ .../model/MessageWithContentReactions.java | 3 +- .../android/ui/BindingAdapters.java | 23 +++++ .../android/ui/adapter/MessageAdapter.java | 95 ++++++++++++++----- .../android/ui/adapter/MessageComparator.java | 35 +++++-- .../ui/fragment/main/ChatFragment.java | 35 ++++++- .../drawable/background_message_separator.xml | 5 + .../res/layout/item_message_separator.xml | 48 ++++++++++ 8 files changed, 225 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/im/conversations/android/database/model/MessageAdapterItem.java create mode 100644 app/src/main/res/drawable/background_message_separator.xml create mode 100644 app/src/main/res/layout/item_message_separator.xml diff --git a/app/src/main/java/im/conversations/android/database/model/MessageAdapterItem.java b/app/src/main/java/im/conversations/android/database/model/MessageAdapterItem.java new file mode 100644 index 000000000..d2fb55568 --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/model/MessageAdapterItem.java @@ -0,0 +1,15 @@ +package im.conversations.android.database.model; + +import java.time.Instant; + +public sealed interface MessageAdapterItem + permits MessageAdapterItem.MessageDateSeparator, MessageWithContentReactions { + + final class MessageDateSeparator implements MessageAdapterItem { + public final Instant date; + + public MessageDateSeparator(Instant date) { + this.date = date; + } + } +} diff --git a/app/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java b/app/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java index 81ae1d6e5..e5b9b5ce6 100644 --- a/app/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java +++ b/app/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java @@ -25,7 +25,8 @@ import org.jxmpp.jid.impl.JidCreate; import org.jxmpp.jid.parts.Resourcepart; import org.whispersystems.libsignal.IdentityKey; -public class MessageWithContentReactions implements IndividualName, KnownSender { +public final class MessageWithContentReactions + implements IndividualName, KnownSender, MessageAdapterItem { public long accountId; diff --git a/app/src/main/java/im/conversations/android/ui/BindingAdapters.java b/app/src/main/java/im/conversations/android/ui/BindingAdapters.java index f059dc696..4fca5f824 100644 --- a/app/src/main/java/im/conversations/android/ui/BindingAdapters.java +++ b/app/src/main/java/im/conversations/android/ui/BindingAdapters.java @@ -105,6 +105,29 @@ public class BindingAdapters { } } + @BindingAdapter("date") + public static void setDate(final TextView textView, final Instant instant) { + if (instant == null || instant.toEpochMilli() <= 0) { + textView.setVisibility(View.INVISIBLE); + } else { + final Context context = textView.getContext(); + final Instant now = Instant.now(); + if (sameYear(instant, now) || now.minus(THREE_MONTH).isBefore(instant)) { + textView.setVisibility(View.VISIBLE); + textView.setText( + DateUtils.formatDateTime( + context, + instant.toEpochMilli(), + DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_YEAR)); + } else { + textView.setVisibility(View.VISIBLE); + textView.setText( + DateUtils.formatDateTime( + context, instant.toEpochMilli(), DateUtils.FORMAT_SHOW_DATE)); + } + } + } + @BindingAdapter("android:text") public static void setSender(final TextView textView, final ChatOverviewItem.Sender sender) { if (sender == null) { diff --git a/app/src/main/java/im/conversations/android/ui/adapter/MessageAdapter.java b/app/src/main/java/im/conversations/android/ui/adapter/MessageAdapter.java index 84298dc27..ca87c12c8 100644 --- a/app/src/main/java/im/conversations/android/ui/adapter/MessageAdapter.java +++ b/app/src/main/java/im/conversations/android/ui/adapter/MessageAdapter.java @@ -9,32 +9,35 @@ import androidx.paging.PagingDataAdapter; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import im.conversations.android.R; +import im.conversations.android.database.model.MessageAdapterItem; import im.conversations.android.database.model.MessageWithContentReactions; import im.conversations.android.databinding.ItemMessageReceivedBinding; import im.conversations.android.databinding.ItemMessageSentBinding; +import im.conversations.android.databinding.ItemMessageSeparatorBinding; import im.conversations.android.ui.AvatarFetcher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class MessageAdapter - extends PagingDataAdapter< - MessageWithContentReactions, MessageAdapter.AbstractMessageViewHolder> { + extends PagingDataAdapter { private static final int VIEW_TYPE_RECEIVED = 0; private static final int VIEW_TYPE_SENT = 1; + private static final int VIEW_TYPE_SEPARATOR = 2; private static final Logger LOGGER = LoggerFactory.getLogger(MessageAdapter.class); - public MessageAdapter( - @NonNull DiffUtil.ItemCallback diffCallback) { + public MessageAdapter(@NonNull DiffUtil.ItemCallback diffCallback) { super(diffCallback); } @Override public int getItemViewType(final int position) { - final var message = getItem(position); - if (message != null && message.outgoing) { - return VIEW_TYPE_SENT; + final var item = peek(position); + if (item instanceof MessageAdapterItem.MessageDateSeparator) { + return VIEW_TYPE_SEPARATOR; + } else if (item instanceof MessageWithContentReactions m) { + return m.outgoing ? VIEW_TYPE_SENT : VIEW_TYPE_RECEIVED; } else { return VIEW_TYPE_RECEIVED; } @@ -53,26 +56,47 @@ public class MessageAdapter return new MessageSentViewHolder( DataBindingUtil.inflate( layoutInflater, R.layout.item_message_sent, parent, false)); + } else if (viewType == VIEW_TYPE_SEPARATOR) { + return new MessageDateSeparator( + DataBindingUtil.inflate( + layoutInflater, R.layout.item_message_separator, parent, false)); } throw new IllegalArgumentException(String.format("viewType %d not implemented", viewType)); } @Override public void onBindViewHolder(@NonNull AbstractMessageViewHolder holder, int position) { - final var message = getItem(position); - if (message == null) { - holder.setMessage(null); + final var item = getItem(position); + if (item == null) { + holder.setItem(null); + } else if (item instanceof MessageWithContentReactions message) { + this.onBindViewHolder(holder, message); + } else if (item instanceof MessageAdapterItem.MessageDateSeparator dateSeparator) { + this.onBindViewHolder(holder, dateSeparator); + } else { + throw new IllegalArgumentException( + String.format( + "%s is not a known implementation", item.getClass().getSimpleName())); } - LOGGER.info("onBindViewHolder({})", message == null ? null : message.id); - holder.setMessage(message); + } + + private void onBindViewHolder( + @NonNull final AbstractMessageViewHolder holder, + @NonNull final MessageAdapterItem.MessageDateSeparator dateSeparator) { + holder.setItem(dateSeparator); + } + + private void onBindViewHolder( + @NonNull AbstractMessageViewHolder holder, + @NonNull final MessageWithContentReactions message) { + holder.setItem(message); if (holder instanceof MessageReceivedViewHolder messageReceivedViewHolder) { - final var addressWithName = message == null ? null : message.getAddressWithName(); - final var avatar = message == null ? null : message.getAvatar(); + final var addressWithName = message.getAddressWithName(); + final var avatar = message.getAvatar(); + messageReceivedViewHolder.binding.avatar.setVisibility(View.VISIBLE); if (avatar != null) { - messageReceivedViewHolder.binding.avatar.setVisibility(View.VISIBLE); AvatarFetcher.fetchInto(messageReceivedViewHolder.binding.avatar, avatar); - } else if (addressWithName != null) { - messageReceivedViewHolder.binding.avatar.setVisibility(View.VISIBLE); + } else { AvatarFetcher.setDefault(messageReceivedViewHolder.binding.avatar, addressWithName); } } @@ -84,7 +108,7 @@ public class MessageAdapter super(itemView); } - protected abstract void setMessage(final MessageWithContentReactions message); + protected abstract void setItem(final MessageAdapterItem item); } public static class MessageReceivedViewHolder extends AbstractMessageViewHolder { @@ -97,8 +121,12 @@ public class MessageAdapter } @Override - protected void setMessage(final MessageWithContentReactions message) { - this.binding.setMessage(message); + protected void setItem(final MessageAdapterItem item) { + if (item instanceof MessageWithContentReactions message) { + this.binding.setMessage(message); + } else { + this.binding.setMessage(null); + } } } @@ -112,8 +140,31 @@ public class MessageAdapter } @Override - protected void setMessage(MessageWithContentReactions message) { - this.binding.setMessage(message); + protected void setItem(MessageAdapterItem item) { + if (item instanceof MessageWithContentReactions message) { + this.binding.setMessage(message); + } else { + this.binding.setMessage(null); + } + } + } + + public static class MessageDateSeparator extends AbstractMessageViewHolder { + + private final ItemMessageSeparatorBinding binding; + + private MessageDateSeparator(@NonNull ItemMessageSeparatorBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + @Override + protected void setItem(MessageAdapterItem item) { + if (item instanceof MessageAdapterItem.MessageDateSeparator dateSeparator) { + this.binding.setTimestamp(dateSeparator.date); + } else { + this.binding.setTimestamp(null); + } } } } diff --git a/app/src/main/java/im/conversations/android/ui/adapter/MessageComparator.java b/app/src/main/java/im/conversations/android/ui/adapter/MessageComparator.java index ea12fc708..a40269547 100644 --- a/app/src/main/java/im/conversations/android/ui/adapter/MessageComparator.java +++ b/app/src/main/java/im/conversations/android/ui/adapter/MessageComparator.java @@ -2,29 +2,44 @@ package im.conversations.android.ui.adapter; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; +import im.conversations.android.database.model.MessageAdapterItem; import im.conversations.android.database.model.MessageWithContentReactions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class MessageComparator extends DiffUtil.ItemCallback { +public class MessageComparator extends DiffUtil.ItemCallback { private static final Logger LOGGER = LoggerFactory.getLogger(MessageComparator.class); @Override public boolean areItemsTheSame( - @NonNull MessageWithContentReactions oldItem, - @NonNull MessageWithContentReactions newItem) { - return oldItem.id == newItem.id; + @NonNull MessageAdapterItem oldItem, @NonNull MessageAdapterItem newItem) { + if (oldItem instanceof MessageWithContentReactions oldMessage + && newItem instanceof MessageWithContentReactions newMessage) { + return oldMessage.id == newMessage.id; + } else if (oldItem instanceof MessageAdapterItem.MessageDateSeparator oldSeparator + && newItem instanceof MessageAdapterItem.MessageDateSeparator newSeparator) { + return oldSeparator.date.equals(newSeparator.date); + } else { + return false; + } } @Override public boolean areContentsTheSame( - @NonNull MessageWithContentReactions oldItem, - @NonNull MessageWithContentReactions newItem) { - final var areContentsTheSame = oldItem.equals(newItem); - if (!areContentsTheSame) { - LOGGER.info("Message {} got modified", oldItem.id); + @NonNull MessageAdapterItem oldItem, @NonNull MessageAdapterItem newItem) { + if (oldItem instanceof MessageWithContentReactions oldMessage + && newItem instanceof MessageWithContentReactions newMessage) { + final var areContentsTheSame = oldMessage.equals(newMessage); + if (!areContentsTheSame) { + LOGGER.info("Message {} got modified", oldMessage.id); + } + return areContentsTheSame; + } else if (oldItem instanceof MessageAdapterItem.MessageDateSeparator oldSeparator + && newItem instanceof MessageAdapterItem.MessageDateSeparator newSeparator) { + return oldSeparator.date.equals(newSeparator.date); + } else { + return false; } - return areContentsTheSame; } } diff --git a/app/src/main/java/im/conversations/android/ui/fragment/main/ChatFragment.java b/app/src/main/java/im/conversations/android/ui/fragment/main/ChatFragment.java index a6a1813dd..431b68696 100644 --- a/app/src/main/java/im/conversations/android/ui/fragment/main/ChatFragment.java +++ b/app/src/main/java/im/conversations/android/ui/fragment/main/ChatFragment.java @@ -8,8 +8,12 @@ import androidx.annotation.NonNull; import androidx.databinding.DataBindingUtil; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; +import androidx.paging.PagingData; +import androidx.paging.PagingDataTransforms; import androidx.recyclerview.widget.LinearLayoutManager; +import com.google.common.util.concurrent.MoreExecutors; import im.conversations.android.R; +import im.conversations.android.database.model.MessageAdapterItem; import im.conversations.android.databinding.FragmentChatBinding; import im.conversations.android.ui.Activities; import im.conversations.android.ui.NavControllers; @@ -17,6 +21,7 @@ import im.conversations.android.ui.RecyclerViewScroller; import im.conversations.android.ui.adapter.MessageAdapter; import im.conversations.android.ui.adapter.MessageComparator; import im.conversations.android.ui.model.ChatViewModel; +import java.time.temporal.ChronoUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -53,7 +58,35 @@ public class ChatFragment extends Fragment { .getMessages() .observe( getViewLifecycleOwner(), - pagingData -> messageAdapter.submitData(getLifecycle(), pagingData)); + pagingData -> { + final PagingData foo = + PagingDataTransforms.insertSeparators( + pagingData, + MoreExecutors.directExecutor(), + (before, after) -> { + final var dayBefore = + before == null + ? null + : before.sentAt.truncatedTo( + ChronoUnit.DAYS); + final var dayAfter = + after == null + ? null + : after.sentAt.truncatedTo( + ChronoUnit.DAYS); + if (dayAfter == null && dayBefore != null) { + return new MessageAdapterItem + .MessageDateSeparator(dayBefore); + } else if (dayBefore == null + || dayBefore.equals(dayAfter)) { + return null; + } else { + return new MessageAdapterItem + .MessageDateSeparator(dayBefore); + } + }); + messageAdapter.submitData(getLifecycle(), foo); + }); this.binding.materialToolbar.setNavigationOnClickListener( view -> { NavControllers.findNavController(requireActivity(), R.id.nav_host_fragment) diff --git a/app/src/main/res/drawable/background_message_separator.xml b/app/src/main/res/drawable/background_message_separator.xml new file mode 100644 index 000000000..ef47cd2d3 --- /dev/null +++ b/app/src/main/res/drawable/background_message_separator.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_separator.xml b/app/src/main/res/layout/item_message_separator.xml new file mode 100644 index 000000000..1fc4f9487 --- /dev/null +++ b/app/src/main/res/layout/item_message_separator.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file