2025-12-19 15:11:49 +08:00
|
|
|
|
package com.example.livestreaming;
|
|
|
|
|
|
|
2025-12-22 16:31:46 +08:00
|
|
|
|
import android.graphics.Outline;
|
|
|
|
|
|
import android.net.Uri;
|
|
|
|
|
|
import android.text.TextUtils;
|
2025-12-19 15:11:49 +08:00
|
|
|
|
import android.view.LayoutInflater;
|
|
|
|
|
|
import android.view.View;
|
|
|
|
|
|
import android.view.ViewGroup;
|
2025-12-22 16:31:46 +08:00
|
|
|
|
import android.view.ViewOutlineProvider;
|
|
|
|
|
|
import android.widget.ImageView;
|
2025-12-19 15:11:49 +08:00
|
|
|
|
import android.widget.TextView;
|
|
|
|
|
|
|
|
|
|
|
|
import androidx.annotation.NonNull;
|
2025-12-23 15:37:37 +08:00
|
|
|
|
import androidx.core.content.FileProvider;
|
2025-12-19 15:11:49 +08:00
|
|
|
|
import androidx.recyclerview.widget.DiffUtil;
|
|
|
|
|
|
import androidx.recyclerview.widget.ListAdapter;
|
|
|
|
|
|
import androidx.recyclerview.widget.RecyclerView;
|
|
|
|
|
|
|
2025-12-22 16:31:46 +08:00
|
|
|
|
import com.bumptech.glide.Glide;
|
|
|
|
|
|
|
2025-12-19 15:11:49 +08:00
|
|
|
|
import java.text.SimpleDateFormat;
|
|
|
|
|
|
import java.util.Date;
|
|
|
|
|
|
import java.util.Locale;
|
|
|
|
|
|
|
|
|
|
|
|
public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, RecyclerView.ViewHolder> {
|
|
|
|
|
|
|
2025-12-23 15:37:37 +08:00
|
|
|
|
public interface OnMessageLongClickListener {
|
|
|
|
|
|
void onMessageLongClick(ChatMessage message, int position, View view);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private OnMessageLongClickListener longClickListener;
|
|
|
|
|
|
|
2025-12-19 15:11:49 +08:00
|
|
|
|
private static final int TYPE_INCOMING = 1;
|
|
|
|
|
|
private static final int TYPE_OUTGOING = 2;
|
|
|
|
|
|
|
|
|
|
|
|
private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm", Locale.getDefault());
|
|
|
|
|
|
|
|
|
|
|
|
public ConversationMessagesAdapter() {
|
|
|
|
|
|
super(DIFF);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 15:37:37 +08:00
|
|
|
|
public void setOnMessageLongClickListener(OnMessageLongClickListener listener) {
|
|
|
|
|
|
this.longClickListener = listener;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 15:11:49 +08:00
|
|
|
|
@Override
|
|
|
|
|
|
public int getItemViewType(int position) {
|
|
|
|
|
|
ChatMessage msg = getItem(position);
|
|
|
|
|
|
if (msg == null) return TYPE_INCOMING;
|
|
|
|
|
|
String u = msg.getUsername();
|
|
|
|
|
|
return "我".equals(u) ? TYPE_OUTGOING : TYPE_INCOMING;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@NonNull
|
|
|
|
|
|
@Override
|
|
|
|
|
|
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
|
|
|
|
|
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
|
|
|
|
|
if (viewType == TYPE_OUTGOING) {
|
|
|
|
|
|
View v = inflater.inflate(R.layout.item_conversation_message_outgoing, parent, false);
|
|
|
|
|
|
return new OutgoingVH(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
View v = inflater.inflate(R.layout.item_conversation_message_incoming, parent, false);
|
|
|
|
|
|
return new IncomingVH(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
|
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
|
|
|
|
|
ChatMessage msg = getItem(position);
|
|
|
|
|
|
if (holder instanceof IncomingVH) {
|
2025-12-23 15:37:37 +08:00
|
|
|
|
((IncomingVH) holder).bind(msg, longClickListener);
|
2025-12-19 15:11:49 +08:00
|
|
|
|
} else if (holder instanceof OutgoingVH) {
|
2025-12-23 15:37:37 +08:00
|
|
|
|
((OutgoingVH) holder).bind(msg, longClickListener);
|
2025-12-19 15:11:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static class IncomingVH extends RecyclerView.ViewHolder {
|
2025-12-22 16:31:46 +08:00
|
|
|
|
private final ImageView avatarView;
|
2025-12-19 15:11:49 +08:00
|
|
|
|
private final TextView nameText;
|
|
|
|
|
|
private final TextView msgText;
|
|
|
|
|
|
private final TextView timeText;
|
|
|
|
|
|
|
|
|
|
|
|
IncomingVH(@NonNull View itemView) {
|
|
|
|
|
|
super(itemView);
|
2025-12-22 16:31:46 +08:00
|
|
|
|
avatarView = itemView.findViewById(R.id.avatarView);
|
2025-12-19 15:11:49 +08:00
|
|
|
|
nameText = itemView.findViewById(R.id.nameText);
|
|
|
|
|
|
msgText = itemView.findViewById(R.id.messageText);
|
|
|
|
|
|
timeText = itemView.findViewById(R.id.timeText);
|
2025-12-22 16:31:46 +08:00
|
|
|
|
setupAvatarOutline();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void setupAvatarOutline() {
|
|
|
|
|
|
if (avatarView == null) return;
|
|
|
|
|
|
// 设置圆形裁剪,确保在模拟器上也能正常工作
|
|
|
|
|
|
// 使用post确保在View布局完成后再设置outline
|
|
|
|
|
|
avatarView.post(() -> {
|
|
|
|
|
|
if (avatarView.getWidth() > 0 && avatarView.getHeight() > 0) {
|
|
|
|
|
|
avatarView.setOutlineProvider(new ViewOutlineProvider() {
|
|
|
|
|
|
@Override
|
|
|
|
|
|
public void getOutline(View view, Outline outline) {
|
|
|
|
|
|
outline.setOval(0, 0, view.getWidth(), view.getHeight());
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
avatarView.setClipToOutline(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-12-19 15:11:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 15:37:37 +08:00
|
|
|
|
void bind(ChatMessage message, OnMessageLongClickListener listener) {
|
2025-12-19 15:11:49 +08:00
|
|
|
|
if (message == null) return;
|
|
|
|
|
|
nameText.setText(message.getUsername() != null ? message.getUsername() : "");
|
|
|
|
|
|
msgText.setText(message.getMessage() != null ? message.getMessage() : "");
|
|
|
|
|
|
timeText.setText(TIME_FORMAT.format(new Date(message.getTimestamp())));
|
2025-12-22 16:31:46 +08:00
|
|
|
|
|
2025-12-23 15:37:37 +08:00
|
|
|
|
// 清除之前的监听器,避免重复绑定
|
|
|
|
|
|
itemView.setOnClickListener(null);
|
|
|
|
|
|
itemView.setOnLongClickListener(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 设置长按监听
|
|
|
|
|
|
itemView.setOnLongClickListener(v -> {
|
|
|
|
|
|
if (listener != null) {
|
|
|
|
|
|
int position = getBindingAdapterPosition();
|
|
|
|
|
|
if (position != RecyclerView.NO_POSITION) {
|
|
|
|
|
|
listener.onMessageLongClick(message, position, v);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-22 16:31:46 +08:00
|
|
|
|
// 确保头像圆形裁剪设置正确
|
|
|
|
|
|
setupAvatarOutline();
|
2025-12-23 15:37:37 +08:00
|
|
|
|
// 加载对方头像(优先使用消息中的头像 URL,如果没有则使用默认头像)
|
|
|
|
|
|
loadAvatar(message);
|
2025-12-22 16:31:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 15:37:37 +08:00
|
|
|
|
private void loadAvatar(ChatMessage message) {
|
|
|
|
|
|
// TODO: 接入后端接口 - 加载消息发送者头像
|
|
|
|
|
|
// 接口路径: GET /api/users/{userId}/avatar 或直接从ChatMessage的avatarUrl字段获取
|
|
|
|
|
|
// ChatMessage对象应包含avatarUrl字段,如果为空,则根据userId调用接口获取
|
|
|
|
|
|
// 建议后端在返回消息列表时,每条消息都包含发送者的avatarUrl,避免额外请求
|
2025-12-22 16:31:46 +08:00
|
|
|
|
if (avatarView == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2025-12-23 15:37:37 +08:00
|
|
|
|
// 优先使用消息中的头像 URL(从后端获取)
|
|
|
|
|
|
String avatarUrl = message != null ? message.getAvatarUrl() : null;
|
|
|
|
|
|
if (!TextUtils.isEmpty(avatarUrl)) {
|
|
|
|
|
|
Glide.with(avatarView)
|
|
|
|
|
|
.load(avatarUrl)
|
|
|
|
|
|
.circleCrop()
|
|
|
|
|
|
.error(R.drawable.ic_account_circle_24)
|
|
|
|
|
|
.placeholder(R.drawable.ic_account_circle_24)
|
|
|
|
|
|
.into(avatarView);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果没有头像 URL,暂时根据用户名生成不同的默认头像
|
|
|
|
|
|
// 这样不同发送者至少能通过不同的默认头像区分开
|
|
|
|
|
|
String username = message != null ? message.getUsername() : null;
|
|
|
|
|
|
int defaultAvatarRes = getDefaultAvatarForUsername(username);
|
2025-12-22 16:31:46 +08:00
|
|
|
|
Glide.with(avatarView)
|
2025-12-23 15:37:37 +08:00
|
|
|
|
.load(defaultAvatarRes)
|
2025-12-22 16:31:46 +08:00
|
|
|
|
.circleCrop()
|
|
|
|
|
|
.into(avatarView);
|
|
|
|
|
|
} catch (Exception e) {
|
2025-12-23 15:37:37 +08:00
|
|
|
|
// 如果加载失败,使用默认头像
|
2025-12-22 16:31:46 +08:00
|
|
|
|
Glide.with(avatarView)
|
|
|
|
|
|
.load(R.drawable.ic_account_circle_24)
|
|
|
|
|
|
.circleCrop()
|
|
|
|
|
|
.into(avatarView);
|
|
|
|
|
|
}
|
2025-12-19 15:11:49 +08:00
|
|
|
|
}
|
2025-12-23 15:37:37 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 根据用户名生成一个稳定的默认头像资源
|
|
|
|
|
|
* 这样同一个用户名的消息会显示相同的默认头像
|
|
|
|
|
|
* TODO: 后续接入后端接口后,这个方法可以改为从后端获取头像 URL
|
|
|
|
|
|
*/
|
|
|
|
|
|
private int getDefaultAvatarForUsername(String username) {
|
|
|
|
|
|
if (TextUtils.isEmpty(username)) {
|
|
|
|
|
|
return R.drawable.ic_account_circle_24;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 使用用户名的哈希值来选择不同的默认头像
|
|
|
|
|
|
// 这样不同的发送者至少能通过不同的默认头像区分开
|
|
|
|
|
|
int hash = Math.abs(username.hashCode());
|
|
|
|
|
|
int[] defaultAvatars = {
|
|
|
|
|
|
R.drawable.ic_account_circle_24,
|
|
|
|
|
|
// 如果有其他默认头像资源,可以在这里添加
|
|
|
|
|
|
// R.drawable.default_avatar_1,
|
|
|
|
|
|
// R.drawable.default_avatar_2,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return defaultAvatars[hash % defaultAvatars.length];
|
|
|
|
|
|
}
|
2025-12-19 15:11:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static class OutgoingVH extends RecyclerView.ViewHolder {
|
2025-12-22 16:31:46 +08:00
|
|
|
|
private final ImageView avatarView;
|
2025-12-19 15:11:49 +08:00
|
|
|
|
private final TextView msgText;
|
|
|
|
|
|
private final TextView timeText;
|
2025-12-23 15:37:37 +08:00
|
|
|
|
private final ImageView statusIcon;
|
2025-12-19 15:11:49 +08:00
|
|
|
|
|
|
|
|
|
|
OutgoingVH(@NonNull View itemView) {
|
|
|
|
|
|
super(itemView);
|
2025-12-22 16:31:46 +08:00
|
|
|
|
avatarView = itemView.findViewById(R.id.avatarView);
|
2025-12-19 15:11:49 +08:00
|
|
|
|
msgText = itemView.findViewById(R.id.messageText);
|
|
|
|
|
|
timeText = itemView.findViewById(R.id.timeText);
|
2025-12-23 15:37:37 +08:00
|
|
|
|
statusIcon = itemView.findViewById(R.id.statusIcon);
|
2025-12-22 16:31:46 +08:00
|
|
|
|
setupAvatarOutline();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void setupAvatarOutline() {
|
|
|
|
|
|
if (avatarView == null) return;
|
|
|
|
|
|
// 设置圆形裁剪,确保在模拟器上也能正常工作
|
|
|
|
|
|
// 使用post确保在View布局完成后再设置outline
|
|
|
|
|
|
avatarView.post(() -> {
|
|
|
|
|
|
if (avatarView.getWidth() > 0 && avatarView.getHeight() > 0) {
|
|
|
|
|
|
avatarView.setOutlineProvider(new ViewOutlineProvider() {
|
|
|
|
|
|
@Override
|
|
|
|
|
|
public void getOutline(View view, Outline outline) {
|
|
|
|
|
|
outline.setOval(0, 0, view.getWidth(), view.getHeight());
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
avatarView.setClipToOutline(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-12-19 15:11:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 15:37:37 +08:00
|
|
|
|
void bind(ChatMessage message, OnMessageLongClickListener listener) {
|
2025-12-19 15:11:49 +08:00
|
|
|
|
if (message == null) return;
|
|
|
|
|
|
msgText.setText(message.getMessage() != null ? message.getMessage() : "");
|
|
|
|
|
|
timeText.setText(TIME_FORMAT.format(new Date(message.getTimestamp())));
|
2025-12-22 16:31:46 +08:00
|
|
|
|
|
2025-12-23 15:37:37 +08:00
|
|
|
|
// 显示消息状态图标(仅对发送的消息显示)
|
|
|
|
|
|
if (statusIcon != null && message.getStatus() != null) {
|
|
|
|
|
|
switch (message.getStatus()) {
|
|
|
|
|
|
case SENDING:
|
|
|
|
|
|
statusIcon.setImageResource(R.drawable.ic_clock_24);
|
|
|
|
|
|
statusIcon.setColorFilter(itemView.getContext().getColor(android.R.color.darker_gray));
|
|
|
|
|
|
statusIcon.setVisibility(View.VISIBLE);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case SENT:
|
|
|
|
|
|
statusIcon.setImageResource(R.drawable.ic_check_24);
|
|
|
|
|
|
statusIcon.setColorFilter(itemView.getContext().getColor(android.R.color.darker_gray));
|
|
|
|
|
|
statusIcon.setVisibility(View.VISIBLE);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case READ:
|
|
|
|
|
|
statusIcon.setImageResource(R.drawable.ic_check_double_24);
|
|
|
|
|
|
statusIcon.setColorFilter(0xFF4CAF50);
|
|
|
|
|
|
statusIcon.setVisibility(View.VISIBLE);
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
statusIcon.setVisibility(View.GONE);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清除之前的监听器,避免重复绑定
|
|
|
|
|
|
itemView.setOnClickListener(null);
|
|
|
|
|
|
itemView.setOnLongClickListener(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 设置长按监听
|
|
|
|
|
|
itemView.setOnLongClickListener(v -> {
|
|
|
|
|
|
if (listener != null) {
|
|
|
|
|
|
int position = getBindingAdapterPosition();
|
|
|
|
|
|
if (position != RecyclerView.NO_POSITION) {
|
|
|
|
|
|
listener.onMessageLongClick(message, position, v);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-22 16:31:46 +08:00
|
|
|
|
// 确保头像圆形裁剪设置正确
|
|
|
|
|
|
setupAvatarOutline();
|
|
|
|
|
|
// 加载用户头像
|
|
|
|
|
|
loadAvatar();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void loadAvatar() {
|
|
|
|
|
|
if (avatarView == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
String avatarUri = avatarView.getContext()
|
|
|
|
|
|
.getSharedPreferences("profile_prefs", android.content.Context.MODE_PRIVATE)
|
|
|
|
|
|
.getString("profile_avatar_uri", null);
|
|
|
|
|
|
|
|
|
|
|
|
if (!TextUtils.isEmpty(avatarUri)) {
|
2025-12-23 15:37:37 +08:00
|
|
|
|
Uri uri = Uri.parse(avatarUri);
|
|
|
|
|
|
// 如果是 file:// 协议,尝试转换为 FileProvider URI
|
|
|
|
|
|
if ("file".equals(uri.getScheme())) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
java.io.File file = new java.io.File(uri.getPath());
|
|
|
|
|
|
if (file.exists()) {
|
|
|
|
|
|
uri = FileProvider.getUriForFile(
|
|
|
|
|
|
avatarView.getContext(),
|
|
|
|
|
|
avatarView.getContext().getPackageName() + ".fileprovider",
|
|
|
|
|
|
file
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
// 如果转换失败,使用原始 URI
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-22 16:31:46 +08:00
|
|
|
|
Glide.with(avatarView)
|
2025-12-23 15:37:37 +08:00
|
|
|
|
.load(uri)
|
2025-12-22 16:31:46 +08:00
|
|
|
|
.circleCrop()
|
|
|
|
|
|
.error(R.drawable.ic_account_circle_24)
|
2025-12-23 15:37:37 +08:00
|
|
|
|
.placeholder(R.drawable.ic_account_circle_24)
|
2025-12-22 16:31:46 +08:00
|
|
|
|
.into(avatarView);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int avatarRes = avatarView.getContext()
|
|
|
|
|
|
.getSharedPreferences("profile_prefs", android.content.Context.MODE_PRIVATE)
|
|
|
|
|
|
.getInt("profile_avatar_res", 0);
|
|
|
|
|
|
|
|
|
|
|
|
if (avatarRes != 0) {
|
|
|
|
|
|
Glide.with(avatarView)
|
|
|
|
|
|
.load(avatarRes)
|
|
|
|
|
|
.circleCrop()
|
|
|
|
|
|
.error(R.drawable.ic_account_circle_24)
|
|
|
|
|
|
.into(avatarView);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Glide.with(avatarView)
|
|
|
|
|
|
.load(R.drawable.ic_account_circle_24)
|
|
|
|
|
|
.circleCrop()
|
|
|
|
|
|
.into(avatarView);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
Glide.with(avatarView)
|
|
|
|
|
|
.load(R.drawable.ic_account_circle_24)
|
|
|
|
|
|
.circleCrop()
|
|
|
|
|
|
.into(avatarView);
|
|
|
|
|
|
}
|
2025-12-19 15:11:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static final DiffUtil.ItemCallback<ChatMessage> DIFF = new DiffUtil.ItemCallback<ChatMessage>() {
|
|
|
|
|
|
@Override
|
|
|
|
|
|
public boolean areItemsTheSame(@NonNull ChatMessage oldItem, @NonNull ChatMessage newItem) {
|
2025-12-23 15:37:37 +08:00
|
|
|
|
// 使用messageId作为唯一标识
|
|
|
|
|
|
return oldItem.getMessageId() != null && oldItem.getMessageId().equals(newItem.getMessageId());
|
2025-12-19 15:11:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
|
public boolean areContentsTheSame(@NonNull ChatMessage oldItem, @NonNull ChatMessage newItem) {
|
2025-12-23 15:37:37 +08:00
|
|
|
|
// 比较消息内容、状态等是否相同
|
|
|
|
|
|
return oldItem.getMessageId() != null && oldItem.getMessageId().equals(newItem.getMessageId()) &&
|
|
|
|
|
|
oldItem.getStatus() == newItem.getStatus() &&
|
|
|
|
|
|
oldItem.getMessage() != null && oldItem.getMessage().equals(newItem.getMessage());
|
2025-12-19 15:11:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|