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.whispersystems.libsignal.IdentityKey;
public class MessageWithContentReactions implements IndividualName, KnownSender {
public final class MessageWithContentReactions
implements IndividualName, KnownSender, MessageAdapterItem {
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")
public static void setSender(final TextView textView, final ChatOverviewItem.Sender sender) {
if (sender == null) {

View file

@ -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<MessageAdapterItem, MessageAdapter.AbstractMessageViewHolder> {
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<MessageWithContentReactions> diffCallback) {
public MessageAdapter(@NonNull DiffUtil.ItemCallback<MessageAdapterItem> 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);
}
}
}
}

View file

@ -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<MessageWithContentReactions> {
public class MessageComparator extends DiffUtil.ItemCallback<MessageAdapterItem> {
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;
}
}

View file

@ -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<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(
view -> {
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>