add date separators

This commit is contained in:
Daniel Gultsch 2023-03-27 16:48:35 +02:00
parent 5b777ef657
commit 4bfcf209d7
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
8 changed files with 225 additions and 34 deletions

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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) {

View file

@ -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) {
if (item instanceof MessageWithContentReactions message) {
this.binding.setMessage(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) {
if (item instanceof MessageWithContentReactions message) {
this.binding.setMessage(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);
}
} }
} }
} }

View file

@ -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) {
final var areContentsTheSame = oldMessage.equals(newMessage);
if (!areContentsTheSame) { if (!areContentsTheSame) {
LOGGER.info("Message {} got modified", oldItem.id); LOGGER.info("Message {} got modified", oldMessage.id);
} }
return areContentsTheSame; return areContentsTheSame;
} else if (oldItem instanceof MessageAdapterItem.MessageDateSeparator oldSeparator
&& newItem instanceof MessageAdapterItem.MessageDateSeparator newSeparator) {
return oldSeparator.date.equals(newSeparator.date);
} else {
return false;
}
} }
} }

View file

@ -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)

View file

@ -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>

View 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>