add date separators
This commit is contained in:
parent
5b777ef657
commit
4bfcf209d7
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,7 +25,8 @@ import org.jxmpp.jid.impl.JidCreate;
|
||||||
import org.jxmpp.jid.parts.Resourcepart;
|
import org.jxmpp.jid.parts.Resourcepart;
|
||||||
import org.whispersystems.libsignal.IdentityKey;
|
import org.whispersystems.libsignal.IdentityKey;
|
||||||
|
|
||||||
public class MessageWithContentReactions implements IndividualName, KnownSender {
|
public final class MessageWithContentReactions
|
||||||
|
implements IndividualName, KnownSender, MessageAdapterItem {
|
||||||
|
|
||||||
public long accountId;
|
public long accountId;
|
||||||
|
|
||||||
|
|
|
@ -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")
|
@BindingAdapter("android:text")
|
||||||
public static void setSender(final TextView textView, final ChatOverviewItem.Sender sender) {
|
public static void setSender(final TextView textView, final ChatOverviewItem.Sender sender) {
|
||||||
if (sender == null) {
|
if (sender == null) {
|
||||||
|
|
|
@ -9,32 +9,35 @@ import androidx.paging.PagingDataAdapter;
|
||||||
import androidx.recyclerview.widget.DiffUtil;
|
import androidx.recyclerview.widget.DiffUtil;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
import im.conversations.android.R;
|
import im.conversations.android.R;
|
||||||
|
import im.conversations.android.database.model.MessageAdapterItem;
|
||||||
import im.conversations.android.database.model.MessageWithContentReactions;
|
import im.conversations.android.database.model.MessageWithContentReactions;
|
||||||
import im.conversations.android.databinding.ItemMessageReceivedBinding;
|
import im.conversations.android.databinding.ItemMessageReceivedBinding;
|
||||||
import im.conversations.android.databinding.ItemMessageSentBinding;
|
import im.conversations.android.databinding.ItemMessageSentBinding;
|
||||||
|
import im.conversations.android.databinding.ItemMessageSeparatorBinding;
|
||||||
import im.conversations.android.ui.AvatarFetcher;
|
import im.conversations.android.ui.AvatarFetcher;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
public class MessageAdapter
|
public class MessageAdapter
|
||||||
extends PagingDataAdapter<
|
extends PagingDataAdapter<MessageAdapterItem, MessageAdapter.AbstractMessageViewHolder> {
|
||||||
MessageWithContentReactions, MessageAdapter.AbstractMessageViewHolder> {
|
|
||||||
|
|
||||||
private static final int VIEW_TYPE_RECEIVED = 0;
|
private static final int VIEW_TYPE_RECEIVED = 0;
|
||||||
private static final int VIEW_TYPE_SENT = 1;
|
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);
|
private static final Logger LOGGER = LoggerFactory.getLogger(MessageAdapter.class);
|
||||||
|
|
||||||
public MessageAdapter(
|
public MessageAdapter(@NonNull DiffUtil.ItemCallback<MessageAdapterItem> diffCallback) {
|
||||||
@NonNull DiffUtil.ItemCallback<MessageWithContentReactions> diffCallback) {
|
|
||||||
super(diffCallback);
|
super(diffCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int getItemViewType(final int position) {
|
public int getItemViewType(final int position) {
|
||||||
final var message = getItem(position);
|
final var item = peek(position);
|
||||||
if (message != null && message.outgoing) {
|
if (item instanceof MessageAdapterItem.MessageDateSeparator) {
|
||||||
return VIEW_TYPE_SENT;
|
return VIEW_TYPE_SEPARATOR;
|
||||||
|
} else if (item instanceof MessageWithContentReactions m) {
|
||||||
|
return m.outgoing ? VIEW_TYPE_SENT : VIEW_TYPE_RECEIVED;
|
||||||
} else {
|
} else {
|
||||||
return VIEW_TYPE_RECEIVED;
|
return VIEW_TYPE_RECEIVED;
|
||||||
}
|
}
|
||||||
|
@ -53,26 +56,47 @@ public class MessageAdapter
|
||||||
return new MessageSentViewHolder(
|
return new MessageSentViewHolder(
|
||||||
DataBindingUtil.inflate(
|
DataBindingUtil.inflate(
|
||||||
layoutInflater, R.layout.item_message_sent, parent, false));
|
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));
|
throw new IllegalArgumentException(String.format("viewType %d not implemented", viewType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onBindViewHolder(@NonNull AbstractMessageViewHolder holder, int position) {
|
public void onBindViewHolder(@NonNull AbstractMessageViewHolder holder, int position) {
|
||||||
final var message = getItem(position);
|
final var item = getItem(position);
|
||||||
if (message == null) {
|
if (item == null) {
|
||||||
holder.setMessage(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) {
|
if (holder instanceof MessageReceivedViewHolder messageReceivedViewHolder) {
|
||||||
final var addressWithName = message == null ? null : message.getAddressWithName();
|
final var addressWithName = message.getAddressWithName();
|
||||||
final var avatar = message == null ? null : message.getAvatar();
|
final var avatar = message.getAvatar();
|
||||||
|
messageReceivedViewHolder.binding.avatar.setVisibility(View.VISIBLE);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
messageReceivedViewHolder.binding.avatar.setVisibility(View.VISIBLE);
|
|
||||||
AvatarFetcher.fetchInto(messageReceivedViewHolder.binding.avatar, avatar);
|
AvatarFetcher.fetchInto(messageReceivedViewHolder.binding.avatar, avatar);
|
||||||
} else if (addressWithName != null) {
|
} else {
|
||||||
messageReceivedViewHolder.binding.avatar.setVisibility(View.VISIBLE);
|
|
||||||
AvatarFetcher.setDefault(messageReceivedViewHolder.binding.avatar, addressWithName);
|
AvatarFetcher.setDefault(messageReceivedViewHolder.binding.avatar, addressWithName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,7 +108,7 @@ public class MessageAdapter
|
||||||
super(itemView);
|
super(itemView);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract void setMessage(final MessageWithContentReactions message);
|
protected abstract void setItem(final MessageAdapterItem item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class MessageReceivedViewHolder extends AbstractMessageViewHolder {
|
public static class MessageReceivedViewHolder extends AbstractMessageViewHolder {
|
||||||
|
@ -97,8 +121,12 @@ public class MessageAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void setMessage(final MessageWithContentReactions message) {
|
protected void setItem(final MessageAdapterItem item) {
|
||||||
this.binding.setMessage(message);
|
if (item instanceof MessageWithContentReactions message) {
|
||||||
|
this.binding.setMessage(message);
|
||||||
|
} else {
|
||||||
|
this.binding.setMessage(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,8 +140,31 @@ public class MessageAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void setMessage(MessageWithContentReactions message) {
|
protected void setItem(MessageAdapterItem item) {
|
||||||
this.binding.setMessage(message);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,29 +2,44 @@ package im.conversations.android.ui.adapter;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.recyclerview.widget.DiffUtil;
|
import androidx.recyclerview.widget.DiffUtil;
|
||||||
|
import im.conversations.android.database.model.MessageAdapterItem;
|
||||||
import im.conversations.android.database.model.MessageWithContentReactions;
|
import im.conversations.android.database.model.MessageWithContentReactions;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
public class MessageComparator extends DiffUtil.ItemCallback<MessageWithContentReactions> {
|
public class MessageComparator extends DiffUtil.ItemCallback<MessageAdapterItem> {
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(MessageComparator.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(MessageComparator.class);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean areItemsTheSame(
|
public boolean areItemsTheSame(
|
||||||
@NonNull MessageWithContentReactions oldItem,
|
@NonNull MessageAdapterItem oldItem, @NonNull MessageAdapterItem newItem) {
|
||||||
@NonNull MessageWithContentReactions newItem) {
|
if (oldItem instanceof MessageWithContentReactions oldMessage
|
||||||
return oldItem.id == newItem.id;
|
&& 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
|
@Override
|
||||||
public boolean areContentsTheSame(
|
public boolean areContentsTheSame(
|
||||||
@NonNull MessageWithContentReactions oldItem,
|
@NonNull MessageAdapterItem oldItem, @NonNull MessageAdapterItem newItem) {
|
||||||
@NonNull MessageWithContentReactions newItem) {
|
if (oldItem instanceof MessageWithContentReactions oldMessage
|
||||||
final var areContentsTheSame = oldItem.equals(newItem);
|
&& newItem instanceof MessageWithContentReactions newMessage) {
|
||||||
if (!areContentsTheSame) {
|
final var areContentsTheSame = oldMessage.equals(newMessage);
|
||||||
LOGGER.info("Message {} got modified", oldItem.id);
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,12 @@ import androidx.annotation.NonNull;
|
||||||
import androidx.databinding.DataBindingUtil;
|
import androidx.databinding.DataBindingUtil;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
|
import androidx.paging.PagingData;
|
||||||
|
import androidx.paging.PagingDataTransforms;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import com.google.common.util.concurrent.MoreExecutors;
|
||||||
import im.conversations.android.R;
|
import im.conversations.android.R;
|
||||||
|
import im.conversations.android.database.model.MessageAdapterItem;
|
||||||
import im.conversations.android.databinding.FragmentChatBinding;
|
import im.conversations.android.databinding.FragmentChatBinding;
|
||||||
import im.conversations.android.ui.Activities;
|
import im.conversations.android.ui.Activities;
|
||||||
import im.conversations.android.ui.NavControllers;
|
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.MessageAdapter;
|
||||||
import im.conversations.android.ui.adapter.MessageComparator;
|
import im.conversations.android.ui.adapter.MessageComparator;
|
||||||
import im.conversations.android.ui.model.ChatViewModel;
|
import im.conversations.android.ui.model.ChatViewModel;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@ -53,7 +58,35 @@ public class ChatFragment extends Fragment {
|
||||||
.getMessages()
|
.getMessages()
|
||||||
.observe(
|
.observe(
|
||||||
getViewLifecycleOwner(),
|
getViewLifecycleOwner(),
|
||||||
pagingData -> messageAdapter.submitData(getLifecycle(), pagingData));
|
pagingData -> {
|
||||||
|
final PagingData<MessageAdapterItem> 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(
|
this.binding.materialToolbar.setNavigationOnClickListener(
|
||||||
view -> {
|
view -> {
|
||||||
NavControllers.findNavController(requireActivity(), R.id.nav_host_fragment)
|
NavControllers.findNavController(requireActivity(), R.id.nav_host_fragment)
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="?colorPrimaryContainer"/>
|
||||||
|
<corners android:radius="8dp"/>
|
||||||
|
</shape>
|
48
app/src/main/res/layout/item_message_separator.xml
Normal file
48
app/src/main/res/layout/item_message_separator.xml
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingVertical="6dp">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/content"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="8dp"
|
||||||
|
android:background="@drawable/background_message_separator"
|
||||||
|
android:paddingVertical="2sp"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
app:layout_constrainedWidth="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0.5"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/date"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:date="@{timestamp}"
|
||||||
|
android:textAppearance="?textAppearanceBodyMedium"
|
||||||
|
android:textColor="?colorOnPrimaryContainer"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="June 12th 2022" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<data>
|
||||||
|
|
||||||
|
<import type="android.view.View" />
|
||||||
|
|
||||||
|
<variable
|
||||||
|
name="timestamp"
|
||||||
|
type="java.time.Instant" />
|
||||||
|
</data>
|
||||||
|
</layout>
|
Loading…
Reference in a new issue