在对应的地方添加了todo,表示需要哪些接口

This commit is contained in:
ShiQi 2025-12-23 15:37:37 +08:00
parent c5314d6309
commit 484c17a4d3
47 changed files with 2614 additions and 311 deletions

View File

@ -61,12 +61,6 @@ android {
}
}
// 编译优化
dexOptions {
javaMaxHeapSize = "2g"
preDexLibraries = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17

View File

@ -44,6 +44,10 @@
android:name="com.example.livestreaming.NotificationSettingsActivity"
android:exported="false" />
<activity
android:name="com.example.livestreaming.NotificationsActivity"
android:exported="false" />
<activity
android:name="com.example.livestreaming.WatchHistoryActivity"
android:exported="false" />

View File

@ -1,23 +1,56 @@
package com.example.livestreaming;
import java.util.UUID;
public class ChatMessage {
public enum MessageStatus {
SENDING, // 发送中
SENT, // 已发送
READ // 已读
}
private String messageId;
private String username;
private String message;
private long timestamp;
private boolean isSystemMessage;
private MessageStatus status;
private String avatarUrl; // 发送者头像 URL后续从后端获取
public ChatMessage(String username, String message) {
this.messageId = UUID.randomUUID().toString();
this.username = username;
this.message = message;
this.timestamp = System.currentTimeMillis();
this.isSystemMessage = false;
// 如果是自己发送的消息初始状态为发送中
this.status = "".equals(username) ? MessageStatus.SENDING : MessageStatus.SENT;
}
public ChatMessage(String message, boolean isSystemMessage) {
this.messageId = UUID.randomUUID().toString();
this.message = message;
this.timestamp = System.currentTimeMillis();
this.isSystemMessage = isSystemMessage;
this.username = isSystemMessage ? "系统" : "匿名用户";
this.status = MessageStatus.SENT; // 系统消息默认已发送
}
public ChatMessage(String messageId, String username, String message, long timestamp, boolean isSystemMessage, MessageStatus status) {
this.messageId = messageId;
this.username = username;
this.message = message;
this.timestamp = timestamp;
this.isSystemMessage = isSystemMessage;
this.status = status;
}
public String getMessageId() {
return messageId;
}
public void setMessageId(String messageId) {
this.messageId = messageId;
}
public String getUsername() {
@ -36,6 +69,14 @@ public class ChatMessage {
return isSystemMessage;
}
public MessageStatus getStatus() {
return status;
}
public void setStatus(MessageStatus status) {
this.status = status;
}
public void setUsername(String username) {
this.username = username;
}
@ -51,4 +92,12 @@ public class ChatMessage {
public void setSystemMessage(boolean systemMessage) {
isSystemMessage = systemMessage;
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
}

View File

@ -1,17 +1,25 @@
package com.example.livestreaming;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.view.KeyEvent;
import android.view.MenuItem;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.PopupMenu;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.example.livestreaming.databinding.ActivityConversationBinding;
import com.google.android.material.snackbar.Snackbar;
import java.util.ArrayList;
import java.util.List;
@ -25,6 +33,8 @@ public class ConversationActivity extends AppCompatActivity {
private ConversationMessagesAdapter adapter;
private final List<ChatMessage> messages = new ArrayList<>();
private Handler handler;
private Runnable statusUpdateRunnable;
public static void start(Context context, String conversationId, String title) {
Intent intent = new Intent(context, ConversationActivity.class);
@ -43,6 +53,8 @@ public class ConversationActivity extends AppCompatActivity {
binding = ActivityConversationBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
handler = new Handler(Looper.getMainLooper());
String title = getIntent() != null ? getIntent().getStringExtra(EXTRA_CONVERSATION_TITLE) : null;
binding.titleText.setText(title != null ? title : "会话");
@ -54,7 +66,13 @@ public class ConversationActivity extends AppCompatActivity {
setupMessages();
setupInput();
// 用户进入会话时标记该会话的消息为已读减少未读数量
// TODO: 接入后端接口 - 标记会话消息为已读
// 接口路径: POST /api/conversations/{conversationId}/read
// 请求参数:
// - conversationId: 会话ID路径参数
// - userId: 当前用户ID从token中获取
// 返回数据格式: ApiResponse<{success: boolean}>
// 用户进入会话时标记该会话的所有消息为已读减少未读数量
if (initialUnreadCount > 0) {
UnreadMessageManager.decrementUnreadCount(this, initialUnreadCount);
}
@ -69,19 +87,90 @@ public class ConversationActivity extends AppCompatActivity {
private void setupMessages() {
adapter = new ConversationMessagesAdapter();
// 设置长按监听
adapter.setOnMessageLongClickListener((message, position, view) -> {
showMessageMenu(message, position, view);
});
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
layoutManager.setStackFromEnd(false);
binding.messagesRecyclerView.setLayoutManager(layoutManager);
binding.messagesRecyclerView.setAdapter(adapter);
// TODO: 接入后端接口 - 获取会话消息列表
// 接口路径: GET /api/conversations/{conversationId}/messages
// 请求参数:
// - conversationId: 会话ID路径参数
// - page (可选): 页码用于分页加载历史消息
// - pageSize (可选): 每页数量默认20
// - beforeMessageId (可选): 获取指定消息ID之前的消息用于上拉加载更多
// 返回数据格式: ApiResponse<List<ChatMessage>>
// ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp, status等字段
// 消息列表应按时间正序排列最早的在前面
messages.clear();
String title = binding.titleText.getText() != null ? binding.titleText.getText().toString() : "";
messages.add(new ChatMessage(title, "你好~"));
messages.add(new ChatMessage("", "在的,有什么需要帮忙?"));
ChatMessage incomingMsg = new ChatMessage(title, "你好~");
incomingMsg.setStatus(ChatMessage.MessageStatus.SENT);
messages.add(incomingMsg);
ChatMessage outgoingMsg = new ChatMessage("", "在的,有什么需要帮忙?");
outgoingMsg.setStatus(ChatMessage.MessageStatus.READ);
messages.add(outgoingMsg);
adapter.submitList(new ArrayList<>(messages));
scrollToBottom();
}
private void showMessageMenu(ChatMessage message, int position, View anchorView) {
PopupMenu popupMenu = new PopupMenu(this, anchorView);
popupMenu.getMenu().add(0, 0, 0, "复制");
popupMenu.getMenu().add(0, 1, 0, "删除");
popupMenu.setOnMenuItemClickListener(item -> {
if (item.getItemId() == 0) {
// 复制消息
copyMessage(message);
return true;
} else if (item.getItemId() == 1) {
// 删除消息
deleteMessage(position);
return true;
}
return false;
});
popupMenu.show();
}
private void copyMessage(ChatMessage message) {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("消息", message.getMessage());
clipboard.setPrimaryClip(clip);
Snackbar.make(binding.getRoot(), "已复制到剪贴板", Snackbar.LENGTH_SHORT).show();
}
private void deleteMessage(int position) {
// TODO: 接入后端接口 - 删除消息
// 接口路径: DELETE /api/messages/{messageId}
// 请求参数:
// - messageId: 消息ID路径参数
// - userId: 当前用户ID从token中获取验证是否为消息发送者
// 返回数据格式: ApiResponse<{success: boolean}>
// 删除成功后从本地消息列表移除
if (position < 0 || position >= messages.size()) return;
new AlertDialog.Builder(this)
.setTitle("删除消息")
.setMessage("确定要删除这条消息吗?")
.setPositiveButton("删除", (dialog, which) -> {
messages.remove(position);
adapter.submitList(new ArrayList<>(messages));
Snackbar.make(binding.getRoot(), "消息已删除", Snackbar.LENGTH_SHORT).show();
})
.setNegativeButton("取消", null)
.show();
}
private void setupInput() {
binding.sendButton.setOnClickListener(v -> sendMessage());
@ -99,17 +188,70 @@ public class ConversationActivity extends AppCompatActivity {
}
private void sendMessage() {
// TODO: 接入后端接口 - 发送私信消息
// 接口路径: POST /api/conversations/{conversationId}/messages
// 请求参数:
// - conversationId: 会话ID路径参数
// - message: 消息内容
// - userId: 发送者用户ID从token中获取
// 返回数据格式: ApiResponse<ChatMessage>
// ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp, status等字段
// 发送成功后更新消息状态为SENT并添加到消息列表
// TODO: 接入后端接口 - 接收对方已读状态WebSocket或轮询
// 方案1: WebSocket实时推送
// - 连接: ws://api.example.com/conversations/{conversationId}
// - 接收消息格式: {type: "read", messageId: "xxx"} {type: "new_message", data: ChatMessage}
// 方案2: 轮询检查消息状态
// - 接口路径: GET /api/conversations/{conversationId}/messages/{messageId}/status
// - 返回数据格式: ApiResponse<{status: "sent"|"read"}>
String text = binding.messageInput.getText() != null ? binding.messageInput.getText().toString().trim() : "";
if (TextUtils.isEmpty(text)) return;
messages.add(new ChatMessage("", text));
ChatMessage newMessage = new ChatMessage("", text);
newMessage.setStatus(ChatMessage.MessageStatus.SENDING);
messages.add(newMessage);
adapter.submitList(new ArrayList<>(messages));
binding.messageInput.setText("");
scrollToBottom();
// 模拟消息发送过程发送中 -> 已发送 -> 已读
if (statusUpdateRunnable != null) {
handler.removeCallbacks(statusUpdateRunnable);
}
statusUpdateRunnable = () -> {
// 1秒后更新为已发送
newMessage.setStatus(ChatMessage.MessageStatus.SENT);
adapter.notifyItemChanged(messages.size() - 1);
// 再2秒后更新为已读
handler.postDelayed(() -> {
newMessage.setStatus(ChatMessage.MessageStatus.READ);
adapter.notifyItemChanged(messages.size() - 1);
}, 2000);
};
handler.postDelayed(statusUpdateRunnable, 1000);
}
private void scrollToBottom() {
if (messages.isEmpty()) return;
binding.messagesRecyclerView.post(() -> binding.messagesRecyclerView.scrollToPosition(messages.size() - 1));
binding.messagesRecyclerView.post(() -> {
int lastPosition = messages.size() - 1;
if (lastPosition >= 0) {
binding.messagesRecyclerView.smoothScrollToPosition(lastPosition);
}
});
}
@Override
protected void onDestroy() {
super.onDestroy();
// 清理Handler中的延迟任务防止内存泄漏
if (handler != null && statusUpdateRunnable != null) {
handler.removeCallbacks(statusUpdateRunnable);
}
handler = null;
statusUpdateRunnable = null;
}
}

View File

@ -11,6 +11,7 @@ import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.FileProvider;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
@ -23,6 +24,12 @@ import java.util.Locale;
public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, RecyclerView.ViewHolder> {
public interface OnMessageLongClickListener {
void onMessageLongClick(ChatMessage message, int position, View view);
}
private OnMessageLongClickListener longClickListener;
private static final int TYPE_INCOMING = 1;
private static final int TYPE_OUTGOING = 2;
@ -32,6 +39,10 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
super(DIFF);
}
public void setOnMessageLongClickListener(OnMessageLongClickListener listener) {
this.longClickListener = listener;
}
@Override
public int getItemViewType(int position) {
ChatMessage msg = getItem(position);
@ -56,9 +67,9 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
ChatMessage msg = getItem(position);
if (holder instanceof IncomingVH) {
((IncomingVH) holder).bind(msg);
((IncomingVH) holder).bind(msg, longClickListener);
} else if (holder instanceof OutgoingVH) {
((OutgoingVH) holder).bind(msg);
((OutgoingVH) holder).bind(msg, longClickListener);
}
}
@ -94,49 +105,107 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
});
}
void bind(ChatMessage message) {
void bind(ChatMessage message, OnMessageLongClickListener listener) {
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())));
// 清除之前的监听器避免重复绑定
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;
});
// 确保头像圆形裁剪设置正确
setupAvatarOutline();
// 加载对方头像可以根据用户名或其他信息加载
loadAvatar(message.getUsername());
// 加载对方头像优先使用消息中的头像 URL如果没有则使用默认头像
loadAvatar(message);
}
private void loadAvatar(String username) {
private void loadAvatar(ChatMessage message) {
// TODO: 接入后端接口 - 加载消息发送者头像
// 接口路径: GET /api/users/{userId}/avatar 或直接从ChatMessage的avatarUrl字段获取
// ChatMessage对象应包含avatarUrl字段如果为空则根据userId调用接口获取
// 建议后端在返回消息列表时每条消息都包含发送者的avatarUrl避免额外请求
if (avatarView == null) return;
// 这里可以根据用户名加载对应的头像
// 暂时使用默认头像后续可以根据实际需求从服务器或本地加载
try {
// 可以根据username从SharedPreferences或其他地方加载头像
// 这里先使用默认头像使用Glide确保圆形裁剪
// 优先使用消息中的头像 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);
Glide.with(avatarView)
.load(R.drawable.ic_account_circle_24)
.load(defaultAvatarRes)
.circleCrop()
.into(avatarView);
} catch (Exception e) {
// 如果加载失败使用默认头像
Glide.with(avatarView)
.load(R.drawable.ic_account_circle_24)
.circleCrop()
.into(avatarView);
}
}
/**
* 根据用户名生成一个稳定的默认头像资源
* 这样同一个用户名的消息会显示相同的默认头像
* 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];
}
}
static class OutgoingVH extends RecyclerView.ViewHolder {
private final ImageView avatarView;
private final TextView msgText;
private final TextView timeText;
private final ImageView statusIcon;
OutgoingVH(@NonNull View itemView) {
super(itemView);
avatarView = itemView.findViewById(R.id.avatarView);
msgText = itemView.findViewById(R.id.messageText);
timeText = itemView.findViewById(R.id.timeText);
statusIcon = itemView.findViewById(R.id.statusIcon);
setupAvatarOutline();
}
@ -157,11 +226,51 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
});
}
void bind(ChatMessage message) {
void bind(ChatMessage message, OnMessageLongClickListener listener) {
if (message == null) return;
msgText.setText(message.getMessage() != null ? message.getMessage() : "");
timeText.setText(TIME_FORMAT.format(new Date(message.getTimestamp())));
// 显示消息状态图标仅对发送的消息显示
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;
});
// 确保头像圆形裁剪设置正确
setupAvatarOutline();
// 加载用户头像
@ -177,10 +286,27 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
.getString("profile_avatar_uri", null);
if (!TextUtils.isEmpty(avatarUri)) {
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
}
}
Glide.with(avatarView)
.load(Uri.parse(avatarUri))
.load(uri)
.circleCrop()
.error(R.drawable.ic_account_circle_24)
.placeholder(R.drawable.ic_account_circle_24)
.into(avatarView);
return;
}
@ -213,12 +339,16 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
private static final DiffUtil.ItemCallback<ChatMessage> DIFF = new DiffUtil.ItemCallback<ChatMessage>() {
@Override
public boolean areItemsTheSame(@NonNull ChatMessage oldItem, @NonNull ChatMessage newItem) {
return oldItem.getTimestamp() == newItem.getTimestamp();
// 使用messageId作为唯一标识
return oldItem.getMessageId() != null && oldItem.getMessageId().equals(newItem.getMessageId());
}
@Override
public boolean areContentsTheSame(@NonNull ChatMessage oldItem, @NonNull ChatMessage newItem) {
return oldItem.getTimestamp() == newItem.getTimestamp();
// 比较消息内容状态等是否相同
return oldItem.getMessageId() != null && oldItem.getMessageId().equals(newItem.getMessageId()) &&
oldItem.getStatus() == newItem.getStatus() &&
oldItem.getMessage() != null && oldItem.getMessage().equals(newItem.getMessage());
}
};
}

View File

@ -9,6 +9,7 @@ import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import androidx.annotation.NonNull;
import androidx.core.content.FileProvider;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
@ -65,12 +66,6 @@ public class ConversationsAdapter extends ListAdapter<ConversationItem, Conversa
binding.unreadBadge.setVisibility(View.GONE);
}
if (item != null && item.isMuted()) {
binding.muteIcon.setVisibility(View.VISIBLE);
} else {
binding.muteIcon.setVisibility(View.GONE);
}
// 加载头像
loadAvatar(item);
@ -81,24 +76,71 @@ public class ConversationsAdapter extends ListAdapter<ConversationItem, Conversa
}
private void loadAvatar(ConversationItem item) {
// TODO: 接入后端接口 - 加载会话对方头像
// 接口路径: GET /api/users/{userId}/avatar 或直接从ConversationItem的avatarUrl字段获取
// 如果ConversationItem对象包含avatarUrl字段直接使用该URL加载头像
// 如果没有avatarUrl需要根据会话类型私信/群聊调用相应接口获取头像
if (binding.avatar == null) return;
// 设置圆形裁剪
setupAvatarOutline();
// 根据会话ID或标题加载对应的头像
// 这里可以根据实际需求从服务器或本地加载
// 暂时使用默认头像或者根据会话类型加载不同头像
try {
String conversationId = item != null ? item.getId() : null;
// 优先使用ConversationItem中的avatarUrl从后端获取
// 尝试从 SharedPreferences 加载用户头像
String avatarUri = binding.avatar.getContext()
.getSharedPreferences("profile_prefs", android.content.Context.MODE_PRIVATE)
.getString("profile_avatar_uri", null);
// 可以根据conversationId从SharedPreferences或其他地方加载头像
// 这里先使用默认头像使用Glide确保圆形裁剪
if (!TextUtils.isEmpty(avatarUri)) {
// 尝试解析 URI
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(
binding.avatar.getContext(),
binding.avatar.getContext().getPackageName() + ".fileprovider",
file
);
}
} catch (Exception e) {
// 如果转换失败使用原始 URI
}
}
Glide.with(binding.avatar)
.load(uri)
.circleCrop()
.error(R.drawable.ic_account_circle_24)
.placeholder(R.drawable.ic_account_circle_24)
.into(binding.avatar);
return;
}
int avatarRes = binding.avatar.getContext()
.getSharedPreferences("profile_prefs", android.content.Context.MODE_PRIVATE)
.getInt("profile_avatar_res", 0);
if (avatarRes != 0) {
Glide.with(binding.avatar)
.load(avatarRes)
.circleCrop()
.error(R.drawable.ic_account_circle_24)
.placeholder(R.drawable.ic_account_circle_24)
.into(binding.avatar);
return;
}
// 如果没有保存的头像使用默认头像
Glide.with(binding.avatar)
.load(R.drawable.ic_account_circle_24)
.circleCrop()
.into(binding.avatar);
} catch (Exception e) {
// 如果加载失败使用默认头像
Glide.with(binding.avatar)
.load(R.drawable.ic_account_circle_24)
.circleCrop()

View File

@ -32,6 +32,10 @@ public class DrawGuessActivity extends AppCompatActivity {
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNavigation);
bottomNavigation.setOnItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.nav_home) {
@ -57,5 +61,16 @@ public class DrawGuessActivity extends AppCompatActivity {
return true;
});
}
@Override
protected void onResume() {
super.onResume();
if (binding != null) {
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
bottomNav.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNav);
}
}
}

View File

@ -216,7 +216,8 @@ public class EditProfileActivity extends AppCompatActivity {
}
out.flush();
return Uri.fromFile(file);
// 使用 FileProvider 生成 URI确保在 Android 10+ 上也能正常访问
return FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", file);
} catch (Exception e) {
Toast.makeText(this, "头像保存失败", Toast.LENGTH_SHORT).show();
return null;

View File

@ -35,6 +35,15 @@ public class FansListActivity extends AppCompatActivity {
Toast.makeText(this, "打开粉丝:" + item.getName(), Toast.LENGTH_SHORT).show();
});
// TODO: 接入后端接口 - 获取粉丝列表
// 接口路径: GET /api/fans
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<User>>
// User对象应包含: id, name, avatarUrl, bio, isLive, followTime等字段
// 列表应按关注时间倒序排列最新关注的在前
binding.fansRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.fansRecyclerView.setAdapter(adapter);
adapter.submitList(buildDemoFans());

View File

@ -32,6 +32,10 @@ public class FindGameActivity extends AppCompatActivity {
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNavigation);
bottomNavigation.setOnItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.nav_home) {
@ -57,5 +61,16 @@ public class FindGameActivity extends AppCompatActivity {
return true;
});
}
@Override
protected void onResume() {
super.onResume();
if (binding != null) {
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
bottomNav.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNav);
}
}
}

View File

@ -320,6 +320,16 @@ public class FishPondActivity extends AppCompatActivity {
}
private void refreshUsers() {
// TODO: 接入后端接口 - 获取附近用户缘池功能
// 接口路径: GET /api/users/nearby
// 请求参数:
// - latitude: 当前用户纬度必填
// - longitude: 当前用户经度必填
// - radius (可选): 搜索半径单位默认5000
// - limit (可选): 返回数量默认6
// 返回数据格式: ApiResponse<List<User>>
// User对象应包含: id, name, avatarUrl, location, bio, distance距离单位, isLive等字段
// 返回距离最近的N个用户用于显示在轨道上
if (binding == null) return;
int[] avatars = new int[] {

View File

@ -35,6 +35,15 @@ public class FollowingListActivity extends AppCompatActivity {
Toast.makeText(this, "打开关注:" + item.getName(), Toast.LENGTH_SHORT).show();
});
// TODO: 接入后端接口 - 获取关注列表
// 接口路径: GET /api/following
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<User>>
// User对象应包含: id, name, avatarUrl, bio, isLive, lastLiveTime, followTime等字段
// 列表应按关注时间倒序或最后直播时间倒序排列
binding.followingRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.followingRecyclerView.setAdapter(adapter);
adapter.submitList(buildDemoFollowing());

View File

@ -33,6 +33,10 @@ public class HeartbeatSignalActivity extends AppCompatActivity {
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNavigation);
bottomNavigation.setOnItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.nav_home) {
@ -58,5 +62,16 @@ public class HeartbeatSignalActivity extends AppCompatActivity {
return true;
});
}
@Override
protected void onResume() {
super.onResume();
if (binding != null) {
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
bottomNav.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNav);
}
}
}

View File

@ -32,6 +32,10 @@ public class KTVTogetherActivity extends AppCompatActivity {
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNavigation);
bottomNavigation.setOnItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.nav_home) {
@ -57,5 +61,16 @@ public class KTVTogetherActivity extends AppCompatActivity {
return true;
});
}
@Override
protected void onResume() {
super.onResume();
if (binding != null) {
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
bottomNav.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNav);
}
}
}

View File

@ -35,6 +35,15 @@ public class LikesListActivity extends AppCompatActivity {
Toast.makeText(this, "查看获赞:" + item.getTitle(), Toast.LENGTH_SHORT).show();
});
// TODO: 接入后端接口 - 获取获赞列表
// 接口路径: GET /api/likes
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<LikeItem>>
// LikeItem对象应包含: id, userId, username, avatarUrl, targetType (room/work), targetId, targetTitle, likeTime等字段
// 列表应按点赞时间倒序排列最新点赞的在前面
binding.likesRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.likesRecyclerView.setAdapter(adapter);
adapter.submitList(buildDemoLikes());

View File

@ -2,8 +2,6 @@ package com.example.livestreaming;
import android.app.Application;
import android.app.Application;
/**
* 自定义Application类用于初始化各种组件
* 包括内存泄漏检测等
@ -16,5 +14,8 @@ public class LiveStreamingApplication extends Application {
// 初始化LeakCanary内存泄漏检测仅在debug版本中生效
// LeakCanary会自动在debug版本中初始化无需手动调用
// 初始化通知渠道
LocalNotificationManager.createNotificationChannel(this);
}
}

View File

@ -0,0 +1,186 @@
package com.example.livestreaming;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import androidx.core.app.NotificationCompat;
/**
* 本地通知管理器
* 用于发送本地通知即使后端未就绪
*/
public class LocalNotificationManager {
private static final String CHANNEL_ID = "livestreaming_notifications";
private static final String CHANNEL_NAME = "直播应用通知";
private static final String PREFS_NAME = "notification_prefs";
private static final String KEY_NOTIFICATION_ID = "notification_id";
/**
* 初始化通知渠道Android 8.0+需要
*/
public static void createNotificationChannel(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
if (notificationManager == null) return;
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT
);
channel.setDescription("直播应用通知渠道");
channel.enableLights(true);
channel.enableVibration(true);
notificationManager.createNotificationChannel(channel);
}
}
/**
* 发送通知
*/
public static void sendNotification(Context context, String title, String content, NotificationItem.Type type) {
if (context == null || title == null || content == null) return;
// 检查通知设置
if (!shouldShowNotification(context, type)) {
return;
}
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
if (notificationManager == null) return;
// 创建点击通知后的Intent
Intent intent = new Intent(context, NotificationsActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
PendingIntent pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
// 构建通知
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(getIconForType(type))
.setContentTitle(title)
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setStyle(new NotificationCompat.BigTextStyle().bigText(content));
// 发送通知
int notificationId = getNextNotificationId(context);
notificationManager.notify(notificationId, builder.build());
}
/**
* 发送系统通知
*/
public static void sendSystemNotification(Context context, String title, String content) {
sendNotification(context, title, content, NotificationItem.Type.SYSTEM);
}
/**
* 发送互动通知
*/
public static void sendInteractionNotification(Context context, String title, String content) {
sendNotification(context, title, content, NotificationItem.Type.INTERACTION);
}
/**
* 发送关注通知
*/
public static void sendFollowNotification(Context context, String title, String content) {
sendNotification(context, title, content, NotificationItem.Type.FOLLOW);
}
/**
* 发送私信通知
*/
public static void sendMessageNotification(Context context, String title, String content) {
sendNotification(context, title, content, NotificationItem.Type.MESSAGE);
}
/**
* 发送直播通知
*/
public static void sendLiveNotification(Context context, String title, String content) {
sendNotification(context, title, content, NotificationItem.Type.LIVE);
}
/**
* 检查是否应该显示通知
*/
private static boolean shouldShowNotification(Context context, NotificationItem.Type type) {
SharedPreferences prefs = context.getSharedPreferences("notification_settings", Context.MODE_PRIVATE);
// 检查系统通知总开关
boolean systemEnabled = prefs.getBoolean("system_notifications", true);
if (!systemEnabled) return false;
// 检查免打扰
boolean dndEnabled = prefs.getBoolean("dnd_enabled", false);
if (dndEnabled) {
// 检查当前时间是否在免打扰时段
// 这里简化处理实际应该检查具体时间
}
// 检查具体类型的通知开关
switch (type) {
case FOLLOW:
return prefs.getBoolean("follow_notifications", true);
case INTERACTION:
return prefs.getBoolean("comment_notifications", true);
case MESSAGE:
return prefs.getBoolean("message_notifications", true);
case LIVE:
return prefs.getBoolean("live_notifications", true);
case SYSTEM:
default:
return systemEnabled;
}
}
/**
* 获取通知类型的图标
*/
private static int getIconForType(NotificationItem.Type type) {
if (type == null) return android.R.drawable.ic_dialog_info;
switch (type) {
case SYSTEM:
return android.R.drawable.ic_dialog_info;
case INTERACTION:
return android.R.drawable.ic_menu_share;
case FOLLOW:
return android.R.drawable.ic_menu_myplaces;
case MESSAGE:
return android.R.drawable.ic_dialog_email;
case LIVE:
return android.R.drawable.ic_media_play;
default:
return android.R.drawable.ic_dialog_info;
}
}
/**
* 获取下一个通知ID
*/
private static int getNextNotificationId(Context context) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
int id = prefs.getInt(KEY_NOTIFICATION_ID, 0);
id++;
if (id > 10000) id = 1; // 防止ID过大
prefs.edit().putInt(KEY_NOTIFICATION_ID, id).apply();
return id;
}
}

View File

@ -25,6 +25,7 @@ import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.FileProvider;
import androidx.core.content.ContextCompat;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
@ -81,6 +82,7 @@ public class MainActivity extends AppCompatActivity {
private long lastFetchMs;
private static final int REQUEST_RECORD_AUDIO_PERMISSION = 200;
private static final int REQUEST_LOCATION_PERMISSION = 201;
private SpeechRecognizer speechRecognizer;
private Intent speechRecognizerIntent;
private boolean isListening = false;
@ -99,26 +101,36 @@ public class MainActivity extends AppCompatActivity {
loadAvatarFromPrefs();
setupSpeechRecognizer();
// TODO: 接入后端接口 - 获取未读消息总数
// 接口路径: GET /api/messages/unread/count
// 请求参数: 从token中获取userId
// 返回数据格式: ApiResponse<Integer> ApiResponse<{unreadCount: number}>
// 返回当前用户所有会话的未读消息总数
// 初始化未读消息数量演示数据
if (UnreadMessageManager.getUnreadCount(this) == 0) {
// 从消息列表计算总未读数量
UnreadMessageManager.setUnreadCount(this, calculateTotalUnreadCount());
}
// 清除默认选中状态让所有标签页初始显示为未选中样式
// 初始化顶部标签页数据
initializeTopTabData();
// 默认显示"发现"界面
if (binding != null && binding.topTabs != null) {
// 在布局完成后清除默认选中状态
// 在布局完成后默认选中"发现"标签索引为1关注=0, 发现=1, 附近=2
binding.topTabs.post(() -> {
// 清除所有选中状态
for (int i = 0; i < binding.topTabs.getTabCount(); i++) {
TabLayout.Tab tab = binding.topTabs.getTabAt(i);
if (tab != null && tab.isSelected()) {
// 取消选中但不触发监听器
binding.topTabs.selectTab(null, false);
break;
}
TabLayout.Tab discoverTab = binding.topTabs.getTabAt(1); // "发现"标签的索引
if (discoverTab != null) {
// 选中"发现"标签这会触发 onTabSelected 监听器自动调用 showDiscoverTab()
discoverTab.select();
} else {
// 如果找不到标签直接显示发现页面
showDiscoverTab();
}
});
} else {
// 如果 topTabs 为空直接显示发现页面
showDiscoverTab();
}
// 异步加载资源文件避免阻塞主线程
@ -241,12 +253,8 @@ public class MainActivity extends AppCompatActivity {
animator.setChangeDuration(200); // 变更动画时长
binding.roomsRecyclerView.setItemAnimator(animator);
// 立即显示演示数据提升用户体验
// 注意如果后续需要从网络加载骨架屏会在fetchRooms中显示
allRooms.clear();
allRooms.addAll(buildDemoRooms(20));
// 使用带动画的筛选方法
applyCategoryFilterWithAnimation(currentCategory);
// 注意房间数据会在 showDiscoverTab() 中从 discoverRooms 加载
// 这里不再直接显示数据而是等待顶部标签初始化完成后再显示
}
private void setupUI() {
@ -270,6 +278,16 @@ public class MainActivity extends AppCompatActivity {
finish();
});
// 设置通知图标点击事件如果存在
try {
View notificationIcon = findViewById(R.id.notificationIcon);
if (notificationIcon != null) {
notificationIcon.setOnClickListener(v -> NotificationsActivity.start(this));
}
} catch (Exception e) {
// 如果通知图标不存在忽略错误
}
binding.topTabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
@Override
public void onTabSelected(TabLayout.Tab tab) {
@ -413,10 +431,14 @@ public class MainActivity extends AppCompatActivity {
// 文本为空显示麦克风图标
binding.micIcon.setImageResource(R.drawable.ic_mic_24);
binding.micIcon.setContentDescription("mic");
// 清除搜索筛选显示所有房间
applySearchFilter("");
} else {
// 文本不为空显示搜索图标
binding.micIcon.setImageResource(R.drawable.ic_search_24);
binding.micIcon.setContentDescription("search");
// 实时筛选房间
applySearchFilter(text);
}
}
@ -435,6 +457,13 @@ public class MainActivity extends AppCompatActivity {
startVoiceRecognition();
} else {
// 如果文本不为空执行搜索
// TODO: 接入后端接口 - 搜索功能
// 接口路径: GET /api/search
// 请求参数:
// - keyword: 搜索关键词
// - type (可选): 搜索类型room/user/all
// - page (可选): 页码
// 返回数据格式: ApiResponse<{rooms: Room[], users: User[]}>
// 跳转到搜索页面并传递搜索关键词
SearchActivity.start(MainActivity.this, searchText);
}
@ -642,10 +671,27 @@ public class MainActivity extends AppCompatActivity {
String avatarUri = getSharedPreferences("profile_prefs", MODE_PRIVATE)
.getString("profile_avatar_uri", null);
if (!TextUtils.isEmpty(avatarUri)) {
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(
this,
getPackageName() + ".fileprovider",
file
);
}
} catch (Exception e) {
// 如果转换失败使用原始 URI
}
}
Glide.with(this)
.load(Uri.parse(avatarUri))
.load(uri)
.circleCrop()
.error(R.drawable.ic_account_circle_24)
.placeholder(R.drawable.ic_account_circle_24)
.into(binding.avatarButton);
return;
}
@ -801,7 +847,15 @@ public class MainActivity extends AppCompatActivity {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
ApiClient.getService(getApplicationContext()).createRoom(new CreateRoomRequest(title, streamer))
// TODO: 接入后端接口 - 创建直播间
// 接口路径: POST /api/rooms
// 请求参数: CreateRoomRequest
// - title: 直播间标题
// - streamerName: 主播名称
// - type: 直播类型"live"
// 返回数据格式: ApiResponse<Room>
// Room对象应包含: id, title, streamerName, streamKey, streamUrls (包含rtmp, flv, hls地址)等字段
ApiClient.getService(getApplicationContext()).createRoom(new CreateRoomRequest(title, streamerName, "live"))
.enqueue(new Callback<ApiResponse<Room>>() {
@Override
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) {
@ -933,6 +987,14 @@ public class MainActivity extends AppCompatActivity {
}
private void fetchRooms() {
// TODO: 接入后端接口 - 获取房间列表
// 接口路径: GET /api/rooms
// 请求参数:
// - category (可选): 分类筛选"游戏""才艺"
// - page (可选): 页码用于分页加载
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<Room>>
// Room对象应包含: id, title, streamerName, type, isLive, coverUrl, viewerCount, streamUrls等字段
// 避免重复请求
if (isFetching) return;
@ -1161,6 +1223,47 @@ public class MainActivity extends AppCompatActivity {
.start();
}
/**
* 应用搜索筛选
* 根据搜索文本筛选房间列表仅按房间标题和主播名称不按分类筛选
*/
private void applySearchFilter(String searchQuery) {
String query = searchQuery != null ? searchQuery.trim().toLowerCase() : "";
// 如果搜索文本为空恢复分类筛选
if (query.isEmpty()) {
applyCategoryFilterWithAnimation(currentCategory);
return;
}
// 根据当前顶部标签选择要筛选的数据源
List<Room> sourceRooms;
if ("关注".equals(currentTopTab)) {
sourceRooms = followRooms;
} else if ("附近".equals(currentTopTab)) {
// 附近页面不显示房间不需要搜索筛选
return;
} else {
// 发现页面
sourceRooms = discoverRooms.isEmpty() ? allRooms : discoverRooms;
}
// 只根据搜索文本筛选按房间标题和主播名称不考虑分类
List<Room> searchFiltered = new ArrayList<>();
for (Room r : sourceRooms) {
if (r == null) continue;
String title = r.getTitle() != null ? r.getTitle().toLowerCase() : "";
String streamer = r.getStreamerName() != null ? r.getStreamerName().toLowerCase() : "";
if (title.contains(query) || streamer.contains(query)) {
searchFiltered.add(r);
}
}
// 更新列表
adapter.submitList(searchFiltered);
updateEmptyStateForList(searchFiltered);
}
/**
* 恢复分类标签的选中状态
*/
@ -1445,6 +1548,15 @@ public class MainActivity extends AppCompatActivity {
* 构建关注页面的房间列表已关注主播的直播
*/
private List<Room> buildFollowRooms() {
// TODO: 接入后端接口 - 获取关注主播的直播间列表
// 接口路径: GET /api/following/rooms GET /api/rooms?type=following
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<Room>>
// Room对象应包含: id, title, streamerName, streamerId, type, isLive, coverUrl, viewerCount等字段
// 只返回当前用户已关注的主播正在直播的房间
List<Room> list = new ArrayList<>();
// 从FollowingListActivity获取已关注的主播列表
@ -1478,6 +1590,15 @@ public class MainActivity extends AppCompatActivity {
* 构建发现页面的房间列表推荐算法前端实现
*/
private List<Room> buildDiscoverRooms() {
// TODO: 接入后端接口 - 获取推荐直播间列表
// 接口路径: GET /api/rooms/recommend GET /api/rooms?type=recommend
// 请求参数:
// - userId: 当前用户ID从token中获取用于个性化推荐
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<Room>>
// Room对象应包含: id, title, streamerName, type, isLive, coverUrl, viewerCount, recommendScore等字段
// 后端应根据用户观看历史点赞记录关注关系等进行个性化推荐
List<Room> list = new ArrayList<>();
// 推荐算法基于观看历史点赞等模拟数据
@ -1544,6 +1665,17 @@ public class MainActivity extends AppCompatActivity {
* 构建附近页面的用户列表使用模拟位置数据
*/
private List<NearbyUser> buildNearbyUsers() {
// TODO: 接入后端接口 - 获取附近用户列表
// 接口路径: GET /api/users/nearby
// 请求参数:
// - latitude: 当前用户纬度必填
// - longitude: 当前用户经度必填
// - radius (可选): 搜索半径单位默认5000
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<NearbyUser>>
// NearbyUser对象应包含: id, name, avatarUrl, distance (距离单位), isLive, location等字段
// 需要先获取用户位置权限然后调用此接口
List<NearbyUser> list = new ArrayList<>();
// 模拟位置数据生成不同距离的用户

View File

@ -7,28 +7,38 @@ import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.TypedValue;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.inputmethod.EditorInfo;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
import com.example.livestreaming.databinding.ActivityMessagesBinding;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.textfield.TextInputEditText;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class MessagesActivity extends AppCompatActivity {
private ActivityMessagesBinding binding;
private final List<ConversationItem> conversations = new ArrayList<>();
private final List<ConversationItem> allConversations = new ArrayList<>(); // 保存所有会话用于搜索
private final List<ConversationItem> conversations = new ArrayList<>(); // 当前显示的会话列表
private ConversationsAdapter conversationsAdapter;
private int swipedPosition = RecyclerView.NO_POSITION;
@ -59,6 +69,7 @@ public class MessagesActivity extends AppCompatActivity {
setContentView(binding.getRoot());
setupConversationList();
setupSearch();
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_messages);
@ -106,27 +117,43 @@ public class MessagesActivity extends AppCompatActivity {
conversationsAdapter = new ConversationsAdapter(item -> {
if (item == null) return;
// 启动会话页面传递未读数量
Intent intent = new Intent(this, ConversationActivity.class);
intent.putExtra("extra_conversation_id", item.getId());
intent.putExtra("extra_conversation_title", item.getTitle());
intent.putExtra("extra_unread_count", item.getUnreadCount());
startActivity(intent);
try {
// 启动会话页面传递未读数量
Intent intent = new Intent(this, ConversationActivity.class);
intent.putExtra("extra_conversation_id", item.getId());
intent.putExtra("extra_conversation_title", item.getTitle());
intent.putExtra("extra_unread_count", item.getUnreadCount());
startActivity(intent);
// 用户点击会话时减少该会话的未读数量
if (item.getUnreadCount() > 0) {
// 更新该会话的未读数量为0在实际应用中这里应该更新数据源
// 然后更新总未读数量
UnreadMessageManager.decrementUnreadCount(this, item.getUnreadCount());
// 更新列表中的未读数量显示
updateConversationUnreadCount(item.getId(), 0);
// 用户点击会话时减少该会话的未读数量
if (item.getUnreadCount() > 0) {
// 更新该会话的未读数量为0在实际应用中这里应该更新数据源
// 然后更新总未读数量
UnreadMessageManager.decrementUnreadCount(this, item.getUnreadCount());
// 更新列表中的未读数量显示
updateConversationUnreadCount(item.getId(), 0);
}
} catch (Exception e) {
e.printStackTrace();
Toast.makeText(this, "打开会话失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
});
binding.conversationsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.conversationsRecyclerView.setAdapter(conversationsAdapter);
// TODO: 接入后端接口 - 获取会话列表
// 接口路径: GET /api/conversations
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<ConversationItem>>
// ConversationItem对象应包含: id, title, lastMessage, timeText, unreadCount, isMuted, avatarUrl等字段
// 会话列表应按最后一条消息时间倒序排列
allConversations.clear();
allConversations.addAll(buildDemoConversations());
conversations.clear();
conversations.addAll(buildDemoConversations());
conversations.addAll(allConversations);
conversationsAdapter.submitList(new ArrayList<>(conversations));
// 检查是否需要显示空状态
@ -135,6 +162,91 @@ public class MessagesActivity extends AppCompatActivity {
attachSwipeToDelete(binding.conversationsRecyclerView);
}
private void setupSearch() {
TextInputEditText searchInput = binding.searchInput;
if (searchInput == null) return;
// 监听搜索输入
searchInput.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
filterConversations(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
});
// 监听搜索按钮点击
searchInput.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_SEARCH ||
(event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_DOWN)) {
// 隐藏键盘
View view = getCurrentFocus();
if (view != null) {
android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
}
return true;
}
return false;
});
}
private void filterConversations(String query) {
// TODO: 接入后端接口 - 搜索会话
// 接口路径: GET /api/conversations/search
// 请求参数:
// - keyword: 搜索关键词
// - userId: 当前用户ID从token中获取
// 返回数据格式: ApiResponse<List<ConversationItem>>
// 搜索范围包括会话标题最后一条消息内容对方用户名等
conversations.clear();
if (query == null || query.trim().isEmpty()) {
// 搜索为空显示所有会话
conversations.addAll(allConversations);
} else {
// 过滤会话按标题和最后一条消息内容搜索
String lowerQuery = query.toLowerCase(Locale.getDefault());
for (ConversationItem item : allConversations) {
if (item == null) continue;
// 搜索会话标题
String title = item.getTitle() != null ? item.getTitle().toLowerCase(Locale.getDefault()) : "";
// 搜索最后一条消息内容
String lastMessage = item.getLastMessage() != null ? item.getLastMessage().toLowerCase(Locale.getDefault()) : "";
if (title.contains(lowerQuery) || lastMessage.contains(lowerQuery)) {
conversations.add(item);
}
}
}
conversationsAdapter.submitList(new ArrayList<>(conversations));
updateEmptyState();
}
/**
* 更新空状态显示
*/
private void updateEmptyState() {
if (binding == null) return;
boolean isEmpty = conversations == null || conversations.isEmpty();
if (binding.emptyStateView != null) {
binding.emptyStateView.setVisibility(isEmpty ? View.VISIBLE : View.GONE);
}
if (binding.conversationsRecyclerView != null) {
binding.conversationsRecyclerView.setVisibility(isEmpty ? View.GONE : View.VISIBLE);
}
}
private void attachSwipeToDelete(RecyclerView recyclerView) {
// 两个按钮的总宽度删除按钮 + 标记已读按钮
final float buttonWidth = dp(96);
@ -276,48 +388,89 @@ public class MessagesActivity extends AppCompatActivity {
new ItemTouchHelper(callback).attachToRecyclerView(recyclerView);
recyclerView.setOnTouchListener((v, event) -> {
if (swipedPosition == RecyclerView.NO_POSITION || (deleteButtonRect == null && markReadButtonRect == null)) return false;
// 使用 OnItemTouchListener 处理滑动删除按钮的点击
// 注意只在有展开的项时才处理避免干扰正常的点击事件
recyclerView.addOnItemTouchListener(new OnItemTouchListener() {
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
int action = e.getActionMasked();
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
touchDownX = event.getX();
touchDownY = event.getY();
touchIsClick = true;
return false;
}
// 如果有展开的项且点击的不是展开的项恢复展开的项
if (swipedPosition != RecyclerView.NO_POSITION && action == MotionEvent.ACTION_DOWN) {
View child = rv.findChildViewUnder(e.getX(), e.getY());
if (child != null) {
RecyclerView.ViewHolder vh = rv.getChildViewHolder(child);
if (vh != null && vh.getBindingAdapterPosition() != swipedPosition) {
// 点击的不是展开的项恢复展开的项
recoverSwipedItem();
return false; // 不拦截让点击事件正常处理
}
}
}
if (action == MotionEvent.ACTION_MOVE) {
float dx = Math.abs(event.getX() - touchDownX);
float dy = Math.abs(event.getY() - touchDownY);
if (dx > touchSlop || dy > touchSlop) {
touchIsClick = false;
// 只有在有展开的项时才处理按钮点击
if (swipedPosition == RecyclerView.NO_POSITION || (deleteButtonRect == null && markReadButtonRect == null)) {
return false; // 不拦截让事件正常传递
}
if (action == MotionEvent.ACTION_DOWN) {
touchDownX = e.getX();
touchDownY = e.getY();
touchIsClick = true;
// 不拦截 DOWN 事件 RecyclerView 正常处理
return false;
}
if (action == MotionEvent.ACTION_MOVE) {
float dx = Math.abs(e.getX() - touchDownX);
float dy = Math.abs(e.getY() - touchDownY);
if (dx > touchSlop || dy > touchSlop) {
touchIsClick = false;
}
// 不拦截 MOVE 事件
return false;
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
if (!touchIsClick) {
// 如果不是点击恢复展开的项
recoverSwipedItem();
return false;
}
int pos = swipedPosition;
float x = e.getX();
float y = e.getY();
// 检查点击的是哪个按钮
boolean hitMarkRead = markReadButtonRect != null && markReadButtonRect.contains(x, y);
boolean hitDelete = deleteButtonRect != null && deleteButtonRect.contains(x, y);
if (hitMarkRead || hitDelete) {
// 点击了按钮拦截事件并处理
recoverSwipedItem();
if (hitMarkRead) {
markAsReadAt(pos);
} else if (hitDelete) {
deleteConversationAt(pos);
}
return true; // 拦截事件防止触发 RecyclerView 的点击
}
// 点击的不是按钮区域不拦截 RecyclerView 正常处理点击
return false;
}
return false;
}
if (action == MotionEvent.ACTION_UP) {
if (!touchIsClick) return false;
int pos = swipedPosition;
float x = event.getX();
float y = event.getY();
// 检查点击的是哪个按钮
boolean hitMarkRead = markReadButtonRect != null && markReadButtonRect.contains(x, y);
boolean hitDelete = deleteButtonRect != null && deleteButtonRect.contains(x, y);
recoverSwipedItem();
if (hitMarkRead) {
markAsReadAt(pos);
return true;
} else if (hitDelete) {
deleteConversationAt(pos);
return true;
}
@Override
public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
// 不需要处理
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
// 不需要处理
}
return false;
});
}
@ -335,6 +488,13 @@ public class MessagesActivity extends AppCompatActivity {
* 标记会话为已读
*/
private void markAsReadAt(int position) {
// TODO: 接入后端接口 - 标记会话为已读
// 接口路径: POST /api/conversations/{conversationId}/read
// 请求参数:
// - conversationId: 会话ID路径参数
// - userId: 当前用户ID从token中获取
// 返回数据格式: ApiResponse<{success: boolean}>
// 标记成功后更新本地未读数量为0并更新总未读数量
if (position < 0 || position >= conversations.size()) return;
ConversationItem item = conversations.get(position);
@ -355,14 +515,26 @@ public class MessagesActivity extends AppCompatActivity {
* 删除会话
*/
private void deleteConversationAt(int position) {
// TODO: 接入后端接口 - 删除会话
// 接口路径: DELETE /api/conversations/{conversationId}
// 请求参数:
// - conversationId: 会话ID路径参数
// - userId: 当前用户ID从token中获取
// 返回数据格式: ApiResponse<{success: boolean}>
// 删除成功后从本地列表移除并更新总未读数量
if (position < 0 || position >= conversations.size()) return;
// 获取要删除的会话的未读数量
ConversationItem itemToDelete = conversations.get(position);
int unreadCountToRemove = itemToDelete != null ? itemToDelete.getUnreadCount() : 0;
String itemId = itemToDelete != null ? itemToDelete.getId() : null;
// 删除会话
// 从当前显示列表和全部列表中删除
conversations.remove(position);
if (itemId != null) {
allConversations.removeIf(item -> item != null && item.getId().equals(itemId));
}
if (conversationsAdapter != null) {
conversationsAdapter.submitList(new ArrayList<>(conversations));
}
@ -375,6 +547,8 @@ public class MessagesActivity extends AppCompatActivity {
UnreadMessageManager.updateBadge(binding.bottomNavInclude.bottomNavigation);
}
}
updateEmptyState();
}
private float dp(float value) {

View File

@ -36,6 +36,15 @@ public class MyFriendsActivity extends AppCompatActivity {
binding.friendsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.friendsRecyclerView.setAdapter(adapter);
// TODO: 接入后端接口 - 获取好友列表
// 接口路径: GET /api/friends
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<FriendItem>>
// FriendItem对象应包含: id, name, avatarUrl, subtitle, isOnline, lastOnlineTime等字段
// 列表应按最后在线时间倒序或添加时间倒序排列
all.clear();
all.addAll(buildDemoFriends());
adapter.submitList(new ArrayList<>(all));

View File

@ -0,0 +1,116 @@
package com.example.livestreaming;
import java.util.Date;
/**
* 通知数据模型
*/
public class NotificationItem {
public enum Type {
SYSTEM, // 系统通知
INTERACTION, // 互动通知点赞评论等
FOLLOW, // 关注通知
MESSAGE, // 私信通知
LIVE // 直播通知开播提醒等
}
private String id;
private Type type;
private String title;
private String content;
private String avatarUrl;
private Date timestamp;
private boolean isRead;
private String actionUrl; // 点击通知后的跳转链接
public NotificationItem() {
}
public NotificationItem(String id, Type type, String title, String content, Date timestamp) {
this.id = id;
this.type = type;
this.title = title;
this.content = content;
this.timestamp = timestamp;
this.isRead = false;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public Type getType() {
return type;
}
public void setType(Type type) {
this.type = type;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public Date getTimestamp() {
return timestamp;
}
public void setTimestamp(Date timestamp) {
this.timestamp = timestamp;
}
public boolean isRead() {
return isRead;
}
public void setRead(boolean read) {
isRead = read;
}
public String getActionUrl() {
return actionUrl;
}
public void setActionUrl(String actionUrl) {
this.actionUrl = actionUrl;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
NotificationItem that = (NotificationItem) o;
return id != null && id.equals(that.id);
}
@Override
public int hashCode() {
return id != null ? id.hashCode() : 0;
}
}

View File

@ -0,0 +1,258 @@
package com.example.livestreaming;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.example.livestreaming.databinding.ActivityNotificationsBinding;
import com.google.android.material.tabs.TabLayout;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Random;
public class NotificationsActivity extends AppCompatActivity {
private ActivityNotificationsBinding binding;
private NotificationsAdapter adapter;
private final List<NotificationItem> allNotifications = new ArrayList<>();
private NotificationItem.Type currentFilter = null; // null表示显示全部
public static void start(Context context) {
Intent intent = new Intent(context, NotificationsActivity.class);
context.startActivity(intent);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityNotificationsBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setupUI();
loadNotifications();
}
private void setupUI() {
binding.backButton.setOnClickListener(v -> finish());
binding.titleText.setText("通知");
// 设置适配器
adapter = new NotificationsAdapter(item -> {
// TODO: 接入后端接口 - 标记通知为已读
// 接口路径: POST /api/notifications/{notificationId}/read
// 请求参数:
// - notificationId: 通知ID路径参数
// - userId: 当前用户ID从token中获取
// 返回数据格式: ApiResponse<{success: boolean}>
// 标记成功后更新本地通知的isRead状态
if (item == null) return;
// 标记为已读
item.setRead(true);
adapter.notifyItemChanged(allNotifications.indexOf(item));
// 处理点击事件可以根据actionUrl跳转
String actionUrl = item.getActionUrl();
if (actionUrl != null && !actionUrl.isEmpty()) {
// 这里可以根据actionUrl跳转到相应页面
// 例如跳转到直播间个人主页等
}
});
binding.notificationsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.notificationsRecyclerView.setAdapter(adapter);
// 设置分类标签
binding.categoryTabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
@Override
public void onTabSelected(TabLayout.Tab tab) {
if (tab == null) return;
int position = tab.getPosition();
switch (position) {
case 0: // 全部
currentFilter = null;
break;
case 1: // 系统
currentFilter = NotificationItem.Type.SYSTEM;
break;
case 2: // 互动
currentFilter = NotificationItem.Type.INTERACTION;
break;
case 3: // 关注
currentFilter = NotificationItem.Type.FOLLOW;
break;
case 4: // 私信
currentFilter = NotificationItem.Type.MESSAGE;
break;
case 5: // 直播
currentFilter = NotificationItem.Type.LIVE;
break;
}
filterNotifications();
}
@Override
public void onTabUnselected(TabLayout.Tab tab) {
}
@Override
public void onTabReselected(TabLayout.Tab tab) {
}
});
}
private void loadNotifications() {
// TODO: 接入后端接口 - 获取通知列表
// 接口路径: GET /api/notifications
// 请求参数:
// - userId: 当前用户ID从token中获取
// - type (可选): 通知类型system/interaction/follow/message/livenull表示全部
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<NotificationItem>>
// NotificationItem对象应包含: id, type, title, content, timestamp, isRead, actionUrl等字段
// 列表应按时间倒序排列最新的在前面
allNotifications.clear();
// 生成演示通知数据
Calendar calendar = Calendar.getInstance();
Random random = new Random();
// 系统通知
for (int i = 0; i < 3; i++) {
calendar.add(Calendar.HOUR, -i * 2);
NotificationItem item = new NotificationItem(
"sys_" + i,
NotificationItem.Type.SYSTEM,
"系统通知",
"欢迎使用直播应用!这里有最新的功能和活动。",
calendar.getTime()
);
item.setRead(i > 0);
allNotifications.add(item);
}
// 互动通知
String[] interactionTitles = {"有人点赞了你的作品", "有人评论了你的动态", "有人转发了你的内容"};
for (int i = 0; i < 5; i++) {
calendar.add(Calendar.MINUTE, -i * 30);
NotificationItem item = new NotificationItem(
"interaction_" + i,
NotificationItem.Type.INTERACTION,
interactionTitles[i % interactionTitles.length],
"用户" + (1000 + i) + "给你点了个赞",
calendar.getTime()
);
item.setRead(i > 2);
allNotifications.add(item);
}
// 关注通知
for (int i = 0; i < 4; i++) {
calendar.add(Calendar.HOUR, -i);
NotificationItem item = new NotificationItem(
"follow_" + i,
NotificationItem.Type.FOLLOW,
"新粉丝",
"用户" + (2000 + i) + "关注了你",
calendar.getTime()
);
item.setRead(i > 1);
allNotifications.add(item);
}
// 私信通知
for (int i = 0; i < 6; i++) {
calendar.add(Calendar.MINUTE, -i * 15);
NotificationItem item = new NotificationItem(
"message_" + i,
NotificationItem.Type.MESSAGE,
"新私信",
"用户" + (3000 + i) + "给你发了一条消息",
calendar.getTime()
);
item.setRead(i > 3);
allNotifications.add(item);
}
// 直播通知
String[] liveTitles = {"开播提醒", "直播推荐", "关注的主播开播了"};
for (int i = 0; i < 3; i++) {
calendar.add(Calendar.HOUR, -i * 4);
NotificationItem item = new NotificationItem(
"live_" + i,
NotificationItem.Type.LIVE,
liveTitles[i % liveTitles.length],
"你关注的主播" + (4000 + i) + "正在直播",
calendar.getTime()
);
item.setRead(i > 0);
allNotifications.add(item);
}
// 按时间倒序排序
allNotifications.sort((a, b) -> {
Date dateA = a.getTimestamp();
Date dateB = b.getTimestamp();
if (dateA == null && dateB == null) return 0;
if (dateA == null) return 1;
if (dateB == null) return -1;
return dateB.compareTo(dateA);
});
filterNotifications();
}
private void filterNotifications() {
List<NotificationItem> filtered = new ArrayList<>();
if (currentFilter == null) {
filtered.addAll(allNotifications);
} else {
for (NotificationItem item : allNotifications) {
if (item.getType() == currentFilter) {
filtered.add(item);
}
}
}
adapter.submitList(filtered);
// 显示/隐藏空状态
if (filtered.isEmpty()) {
binding.emptyStateView.setVisibility(View.VISIBLE);
binding.emptyStateView.setIcon(R.drawable.ic_notifications_24);
binding.emptyStateView.setTitle("暂无通知");
String message = currentFilter == null ? "还没有通知" : "还没有" + getFilterName() + "通知";
binding.emptyStateView.setMessage(message);
binding.emptyStateView.hideActionButton();
} else {
binding.emptyStateView.setVisibility(View.GONE);
}
}
private String getFilterName() {
if (currentFilter == null) return "";
switch (currentFilter) {
case SYSTEM:
return "系统";
case INTERACTION:
return "互动";
case FOLLOW:
return "关注";
case MESSAGE:
return "私信";
case LIVE:
return "直播";
default:
return "";
}
}
}

View File

@ -0,0 +1,167 @@
package com.example.livestreaming;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.example.livestreaming.databinding.ItemNotificationBinding;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
public class NotificationsAdapter extends ListAdapter<NotificationItem, NotificationsAdapter.VH> {
public interface OnNotificationClickListener {
void onNotificationClick(NotificationItem item);
}
private final OnNotificationClickListener onNotificationClickListener;
public NotificationsAdapter(OnNotificationClickListener onNotificationClickListener) {
super(DIFF);
this.onNotificationClickListener = onNotificationClickListener;
}
@NonNull
@Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemNotificationBinding binding = ItemNotificationBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new VH(binding, onNotificationClickListener);
}
@Override
public void onBindViewHolder(@NonNull VH holder, int position) {
holder.bind(getItem(position));
}
static class VH extends RecyclerView.ViewHolder {
private final ItemNotificationBinding binding;
private final OnNotificationClickListener onNotificationClickListener;
private final SimpleDateFormat timeFormat = new SimpleDateFormat("MM-dd HH:mm", Locale.getDefault());
private final SimpleDateFormat todayFormat = new SimpleDateFormat("HH:mm", Locale.getDefault());
VH(ItemNotificationBinding binding, OnNotificationClickListener onNotificationClickListener) {
super(binding.getRoot());
this.binding = binding;
this.onNotificationClickListener = onNotificationClickListener;
}
void bind(NotificationItem item) {
if (item == null) return;
// 设置标题和内容
binding.title.setText(item.getTitle() != null ? item.getTitle() : "");
binding.content.setText(item.getContent() != null ? item.getContent() : "");
// 设置时间
Date timestamp = item.getTimestamp();
if (timestamp != null) {
String timeText = formatTime(timestamp);
binding.timeText.setText(timeText);
} else {
binding.timeText.setText("");
}
// 设置未读标记
if (!item.isRead()) {
binding.unreadDot.setVisibility(View.VISIBLE);
} else {
binding.unreadDot.setVisibility(View.GONE);
}
// 加载头像
loadAvatar(item);
// 设置点击事件
binding.getRoot().setOnClickListener(v -> {
if (onNotificationClickListener != null) {
onNotificationClickListener.onNotificationClick(item);
}
});
}
private String formatTime(Date date) {
if (date == null) return "";
Date now = new Date();
long diff = now.getTime() - date.getTime();
// 如果是今天只显示时间
if (diff < 24 * 60 * 60 * 1000 && isToday(date, now)) {
return todayFormat.format(date);
}
// 否则显示日期和时间
return timeFormat.format(date);
}
private boolean isToday(Date date, Date now) {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
return dateFormat.format(date).equals(dateFormat.format(now));
}
private void loadAvatar(NotificationItem item) {
if (binding.avatar == null) return;
String avatarUrl = item.getAvatarUrl();
if (!TextUtils.isEmpty(avatarUrl)) {
Glide.with(binding.avatar)
.load(avatarUrl)
.circleCrop()
.placeholder(R.drawable.ic_account_circle_24)
.error(R.drawable.ic_account_circle_24)
.into(binding.avatar);
} else {
// 根据通知类型设置默认图标
int iconRes = getIconForType(item.getType());
Glide.with(binding.avatar)
.load(iconRes)
.circleCrop()
.into(binding.avatar);
}
}
private int getIconForType(NotificationItem.Type type) {
if (type == null) return R.drawable.ic_notifications_24;
switch (type) {
case SYSTEM:
return R.drawable.ic_notifications_24;
case INTERACTION:
return R.drawable.ic_chat_24;
case FOLLOW:
return R.drawable.ic_people_24;
case MESSAGE:
return R.drawable.ic_chat_24;
case LIVE:
return R.drawable.ic_voice_24;
default:
return R.drawable.ic_notifications_24;
}
}
}
private static final DiffUtil.ItemCallback<NotificationItem> DIFF = new DiffUtil.ItemCallback<NotificationItem>() {
@Override
public boolean areItemsTheSame(@NonNull NotificationItem oldItem, @NonNull NotificationItem newItem) {
String o = oldItem.getId();
String n = newItem.getId();
return o != null && o.equals(n);
}
@Override
public boolean areContentsTheSame(@NonNull NotificationItem oldItem, @NonNull NotificationItem newItem) {
return oldItem.equals(newItem) && oldItem.isRead() == newItem.isRead();
}
};
}

View File

@ -32,6 +32,10 @@ public class OnlineDatingActivity extends AppCompatActivity {
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNavigation);
bottomNavigation.setOnItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.nav_home) {
@ -57,5 +61,16 @@ public class OnlineDatingActivity extends AppCompatActivity {
return true;
});
}
@Override
protected void onResume() {
super.onResume();
if (binding != null) {
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
bottomNav.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNav);
}
}
}

View File

@ -32,6 +32,10 @@ public class PeaceEliteActivity extends AppCompatActivity {
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNavigation);
bottomNavigation.setOnItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.nav_home) {
@ -57,5 +61,16 @@ public class PeaceEliteActivity extends AppCompatActivity {
return true;
});
}
@Override
protected void onResume() {
super.onResume();
if (binding != null) {
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
bottomNav.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNav);
}
}
}

View File

@ -15,8 +15,8 @@ import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.FileProvider;
import androidx.appcompat.app.AlertDialog;
import com.bumptech.glide.Glide;
@ -122,6 +122,14 @@ public class ProfileActivity extends AppCompatActivity {
}
private void loadProfileFromPrefs() {
// TODO: 接入后端接口 - 获取用户资料
// 接口路径: GET /api/users/{userId}/profile
// 请求参数:
// - userId: 用户ID路径参数当前用户从token中获取
// 返回数据格式: ApiResponse<UserProfile>
// UserProfile对象应包含: id, name, avatarUrl, bio, level, badge, birthday, gender, location,
// followingCount, fansCount, likesCount等字段
// 首次加载时从接口获取后续可从本地缓存读取
String n = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_NAME, null);
if (!TextUtils.isEmpty(n)) binding.name.setText(n);
@ -136,10 +144,27 @@ public class ProfileActivity extends AppCompatActivity {
String avatarUri = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_AVATAR_URI, null);
if (!TextUtils.isEmpty(avatarUri)) {
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(
this,
getPackageName() + ".fileprovider",
file
);
}
} catch (Exception e) {
// 如果转换失败使用原始 URI
}
}
Glide.with(this)
.load(Uri.parse(avatarUri))
.load(uri)
.circleCrop()
.error(R.drawable.ic_account_circle_24)
.placeholder(R.drawable.ic_account_circle_24)
.into(binding.avatar);
} else {
int avatarRes = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getInt(KEY_AVATAR_RES, 0);
@ -158,6 +183,18 @@ public class ProfileActivity extends AppCompatActivity {
}
private void setupEditableAreas() {
// TODO: 接入后端接口 - 更新用户资料
// 接口路径: PUT /api/users/{userId}/profile
// 请求参数:
// - userId: 用户ID路径参数从token中获取
// - name (可选): 昵称
// - bio (可选): 个人签名
// - avatarUrl (可选): 头像URL
// - birthday (可选): 生日
// - gender (可选): 性别
// - location (可选): 所在地
// 返回数据格式: ApiResponse<UserProfile>
// 更新成功后同步更新本地缓存和界面显示
binding.name.setOnClickListener(v -> showEditDialog("编辑昵称", binding.name.getText() != null ? binding.name.getText().toString() : "", text -> {
binding.name.setText(text);
getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putString(KEY_NAME, text).apply();
@ -224,6 +261,12 @@ public class ProfileActivity extends AppCompatActivity {
}
});
// TODO: 接入后端接口 - 获取关注/粉丝/获赞数量
// 接口路径: GET /api/users/{userId}/stats
// 请求参数:
// - userId: 用户ID路径参数
// 返回数据格式: ApiResponse<{followingCount: number, fansCount: number, likesCount: number}>
// 在ProfileActivity加载时调用更新关注粉丝获赞数量显示
binding.following.setOnClickListener(v -> FollowingListActivity.start(this));
binding.followers.setOnClickListener(v -> FansListActivity.start(this));
binding.likes.setOnClickListener(v -> LikesListActivity.start(this));
@ -236,20 +279,6 @@ public class ProfileActivity extends AppCompatActivity {
Intent intent = new Intent(this, EditProfileActivity.class);
editProfileLauncher.launch(intent);
});
binding.shareHome.setOnClickListener(v -> {
// TabPlaceholderActivity.start(this, "分享主页");
String idText = binding.idLine.getText() != null ? binding.idLine.getText().toString() : "";
String digits = !TextUtils.isEmpty(idText) ? idText.replaceAll("\\D+", "") : "";
if (TextUtils.isEmpty(digits)) digits = "24187196";
String url = "https://live.example.com/u/" + digits;
ClipboardManager cm = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
if (cm != null) {
cm.setPrimaryClip(ClipData.newPlainText("profile_url", url));
Toast.makeText(this, "主页链接已复制", Toast.LENGTH_SHORT).show();
}
});
binding.shareHome.setOnClickListener(v -> showShareProfileDialog());
binding.addFriendBtn.setOnClickListener(v -> TabPlaceholderActivity.start(this, "加好友"));
}
@ -474,147 +503,6 @@ public class ProfileActivity extends AppCompatActivity {
}
}
private void loadAndDisplayTags() {
String location = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_LOCATION, "");
String gender = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_GENDER, "");
String birthday = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_BIRTHDAY, "");
// 设置所在地标签 - 支持"省份-城市"格式
if (!TextUtils.isEmpty(location)) {
// "省份-城市"格式转换为"省份·城市"显示
String displayLocation = location.replace("-", "·");
binding.tagLocation.setText("IP" + displayLocation);
binding.tagLocation.setVisibility(View.VISIBLE);
} else {
binding.tagLocation.setText("IP广西");
binding.tagLocation.setVisibility(View.VISIBLE);
}
// 设置性别标签
if (!TextUtils.isEmpty(gender)) {
if (gender.contains("")) {
binding.tagGender.setText("");
} else if (gender.contains("")) {
binding.tagGender.setText("");
} else {
binding.tagGender.setText("H");
}
binding.tagGender.setVisibility(View.VISIBLE);
} else {
binding.tagGender.setText("H");
binding.tagGender.setVisibility(View.VISIBLE);
}
// 计算并设置年龄标签
if (!TextUtils.isEmpty(birthday)) {
int age = calculateAge(birthday);
if (age > 0) {
binding.tagAge.setText(age + "");
binding.tagAge.setVisibility(View.VISIBLE);
} else {
binding.tagAge.setVisibility(View.GONE);
}
} else {
binding.tagAge.setVisibility(View.GONE);
}
// 计算并设置星座标签
if (!TextUtils.isEmpty(birthday)) {
String constellation = calculateConstellation(birthday);
if (!TextUtils.isEmpty(constellation)) {
binding.tagConstellation.setText(constellation);
binding.tagConstellation.setVisibility(View.VISIBLE);
} else {
binding.tagConstellation.setVisibility(View.GONE);
}
} else {
binding.tagConstellation.setVisibility(View.GONE);
}
}
private void loadProfileInfo() {
String name = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_NAME, "爱你");
String location = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_LOCATION, "广西");
String bio = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_BIO, null);
if (binding.profileInfoLine1 != null) {
binding.profileInfoLine1.setText("昵称:" + name);
}
if (binding.profileInfoLine2 != null) {
String locationText = !TextUtils.isEmpty(location) ? location : "广西";
binding.profileInfoLine2.setText("地区:" + locationText);
}
if (binding.profileInfoLine3 != null) {
String bioText = (!TextUtils.isEmpty(bio) && !BIO_HINT_TEXT.equals(bio)) ? bio : "填写个人签名更容易获得关注";
binding.profileInfoLine3.setText("签名:" + bioText);
}
}
private int calculateAge(String birthdayStr) {
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
Date birthDate = sdf.parse(birthdayStr);
if (birthDate == null) return 0;
Calendar birth = Calendar.getInstance();
birth.setTime(birthDate);
Calendar now = Calendar.getInstance();
int age = now.get(Calendar.YEAR) - birth.get(Calendar.YEAR);
if (now.get(Calendar.DAY_OF_YEAR) < birth.get(Calendar.DAY_OF_YEAR)) {
age--;
}
return age;
} catch (ParseException e) {
return 0;
}
}
private String calculateConstellation(String birthdayStr) {
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
Date birthDate = sdf.parse(birthdayStr);
if (birthDate == null) return "";
Calendar cal = Calendar.getInstance();
cal.setTime(birthDate);
int month = cal.get(Calendar.MONTH) + 1; // Calendar.MONTH 从0开始
int day = cal.get(Calendar.DAY_OF_MONTH);
// 星座计算
if ((month == 3 && day >= 21) || (month == 4 && day <= 19)) {
return "白羊座";
} else if ((month == 4 && day >= 20) || (month == 5 && day <= 20)) {
return "金牛座";
} else if ((month == 5 && day >= 21) || (month == 6 && day <= 21)) {
return "双子座";
} else if ((month == 6 && day >= 22) || (month == 7 && day <= 22)) {
return "巨蟹座";
} else if ((month == 7 && day >= 23) || (month == 8 && day <= 22)) {
return "狮子座";
} else if ((month == 8 && day >= 23) || (month == 9 && day <= 22)) {
return "处女座";
} else if ((month == 9 && day >= 23) || (month == 10 && day <= 23)) {
return "天秤座";
} else if ((month == 10 && day >= 24) || (month == 11 && day <= 22)) {
return "天蝎座";
} else if ((month == 11 && day >= 23) || (month == 12 && day <= 21)) {
return "射手座";
} else if ((month == 12 && day >= 22) || (month == 1 && day <= 19)) {
return "摩羯座";
} else if ((month == 1 && day >= 20) || (month == 2 && day <= 18)) {
return "水瓶座";
} else if ((month == 2 && day >= 19) || (month == 3 && day <= 20)) {
return "双鱼座";
}
return "";
} catch (ParseException e) {
return "";
}
}
/**
* 显示分享个人主页对话框
*/

View File

@ -28,6 +28,7 @@ import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.Room;
import com.example.livestreaming.net.StreamConfig;
import com.example.livestreaming.ShareUtils;
import tv.danmaku.ijk.media.player.IMediaPlayer;
import tv.danmaku.ijk.media.player.IjkMediaPlayer;
@ -130,11 +131,21 @@ public class RoomDetailActivity extends AppCompatActivity {
binding.fullscreenButton.setOnClickListener(v -> toggleFullscreen());
// 关注按钮
// TODO: 接入后端接口 - 关注/取消关注主播
// 接口路径: POST /api/follow DELETE /api/follow
// 请求参数:
// - streamerId: 主播用户ID
// - action: "follow" "unfollow"
// 返回数据格式: ApiResponse<{success: boolean, message: string}>
// 关注成功后更新按钮状态为"已关注"并禁用按钮
binding.followButton.setOnClickListener(v -> {
Toast.makeText(this, "已关注主播", Toast.LENGTH_SHORT).show();
binding.followButton.setText("已关注");
binding.followButton.setEnabled(false);
});
// 分享按钮
binding.shareButton.setOnClickListener(v -> shareRoom());
}
private void setupChat() {
@ -160,6 +171,15 @@ public class RoomDetailActivity extends AppCompatActivity {
}
private void sendMessage() {
// TODO: 接入后端接口 - 发送直播间弹幕消息
// 接口路径: POST /api/rooms/{roomId}/messages
// 请求参数:
// - roomId: 房间ID路径参数
// - message: 消息内容
// - userId: 发送者用户ID从token中获取
// 返回数据格式: ApiResponse<ChatMessage>
// ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp等字段
// 发送成功后将消息添加到本地列表并显示
String message = binding.chatInput.getText() != null ?
binding.chatInput.getText().toString().trim() : "";
@ -167,6 +187,14 @@ public class RoomDetailActivity extends AppCompatActivity {
addChatMessage(new ChatMessage("", message));
binding.chatInput.setText("");
// TODO: 接入后端接口 - 接收直播间弹幕消息WebSocket或轮询
// 方案1: WebSocket实时推送
// - 连接: ws://api.example.com/rooms/{roomId}/chat
// - 接收消息格式: {type: "message", data: ChatMessage}
// 方案2: 轮询获取新消息
// - 接口路径: GET /api/rooms/{roomId}/messages?lastMessageId={lastId}
// - 返回数据格式: ApiResponse<List<ChatMessage>>
// - 每3-5秒轮询一次获取lastMessageId之后的新消息
// 模拟其他用户回复
handler.postDelayed(() -> {
if (random.nextFloat() < 0.3f) { // 30%概率有人回复
@ -272,10 +300,18 @@ public class RoomDetailActivity extends AppCompatActivity {
}
private void startChatSimulation() {
// TODO: 接入后端接口 - 初始化时获取历史弹幕消息
// 接口路径: GET /api/rooms/{roomId}/messages
// 请求参数:
// - roomId: 房间ID路径参数
// - limit (可选): 获取最近N条消息默认50
// 返回数据格式: ApiResponse<List<ChatMessage>>
// 进入直播间时先获取最近50条历史消息显示在聊天列表中
stopChatSimulation();
chatSimulationRunnable = () -> {
if (isFinishing() || isDestroyed()) return;
// TODO: 这里应该改为从WebSocket或轮询接口获取新消息而不是模拟生成
// 随机生成弹幕降低概率
if (random.nextFloat() < 0.25f) { // 25%概率生成弹幕
String user = simulatedUsers[random.nextInt(simulatedUsers.length)];
@ -302,6 +338,12 @@ public class RoomDetailActivity extends AppCompatActivity {
private boolean isFirstLoad = true;
private void fetchRoom() {
// TODO: 接入后端接口 - 获取房间详情
// 接口路径: GET /api/rooms/{roomId}
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<Room>
// Room对象应包含: id, title, streamerName, streamerId, type, isLive, coverUrl, viewerCount,
// streamUrls (包含flv, hls, rtmp地址), description, startTime等字段
if (TextUtils.isEmpty(roomId)) {
Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show();
finish();
@ -346,6 +388,18 @@ public class RoomDetailActivity extends AppCompatActivity {
});
}
private void shareRoom() {
if (room == null || roomId == null) {
Toast.makeText(this, "房间信息不可用", Toast.LENGTH_SHORT).show();
return;
}
String shareLink = ShareUtils.generateRoomShareLink(roomId);
String title = room.getTitle() != null ? room.getTitle() : "直播间";
String text = "来看看这个精彩的直播:" + title;
ShareUtils.shareLink(this, shareLink, title, text);
}
private void bindRoom(Room r) {
String title = r.getTitle() != null ? r.getTitle() : "直播间";
String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播";
@ -362,6 +416,11 @@ public class RoomDetailActivity extends AppCompatActivity {
binding.liveTag.setVisibility(View.VISIBLE);
binding.offlineLayout.setVisibility(View.GONE);
// TODO: 接入后端接口 - 获取实时观看人数
// 接口路径: GET /api/rooms/{roomId}/viewers/count
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<{viewerCount: number}>
// 建议使用WebSocket实时推送观看人数变化或每10-15秒轮询一次
// 设置观看人数模拟
int viewerCount = r.getViewerCount() > 0 ? r.getViewerCount() :
100 + random.nextInt(500);

View File

@ -73,6 +73,16 @@ public class SearchActivity extends AppCompatActivity {
binding.resultsRecyclerView.setLayoutManager(glm);
binding.resultsRecyclerView.setAdapter(adapter);
// TODO: 接入后端接口 - 搜索房间/主播
// 接口路径: GET /api/search
// 请求参数:
// - keyword: 搜索关键词必填
// - type (可选): 搜索类型room/user/all默认all
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<{rooms: Room[], users: User[]}>
// Room对象应包含: id, title, streamerName, type, isLive, coverUrl等字段
// User对象应包含: id, name, avatarUrl, bio, isLive等字段
all.clear();
all.addAll(buildDemoRooms(24));
adapter.submitList(new ArrayList<>(all));

View File

@ -1,6 +1,5 @@
package com.example.livestreaming;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInfo;
@ -54,7 +53,7 @@ public class SettingsPageActivity extends AppCompatActivity {
page = getIntent() != null ? getIntent().getStringExtra(EXTRA_PAGE) : null;
if (page == null) page = "";
String title = resolveTitle(currentPage);
String title = resolveTitle(page);
binding.titleText.setText(title);
adapter = new MoreAdapter(item -> {
@ -62,6 +61,7 @@ public class SettingsPageActivity extends AppCompatActivity {
if (item.getType() != MoreItem.Type.ROW) return;
String t = item.getTitle() != null ? item.getTitle() : "";
// 处理服务器设置相关项目
if ("服务器设置".equals(t)) {
SettingsPageActivity.start(this, PAGE_SERVER);
return;
@ -97,7 +97,14 @@ public class SettingsPageActivity extends AppCompatActivity {
return;
}
Toast.makeText(this, "点击:" + t, Toast.LENGTH_SHORT).show();
// 处理其他页面的点击事件
if (PAGE_SERVER.equals(page)) {
// 服务器设置页面的其他项目已在上面处理
return;
}
// 调用统一的点击处理方法
handleItemClick(item);
});
binding.recyclerView.setLayoutManager(new LinearLayoutManager(this));
@ -109,7 +116,7 @@ public class SettingsPageActivity extends AppCompatActivity {
private void handleItemClick(MoreItem item) {
String title = item.getTitle() != null ? item.getTitle() : "";
switch (currentPage) {
switch (page) {
case PAGE_ACCOUNT_SECURITY:
handleAccountSecurityClick(title);
break;
@ -152,7 +159,9 @@ public class SettingsPageActivity extends AppCompatActivity {
}
private void handleNotificationsClick(String title) {
if ("系统通知".equals(title) || "免打扰".equals(title)) {
if ("通知设置".equals(title)) {
NotificationSettingsActivity.start(this);
} else if ("系统通知".equals(title) || "免打扰".equals(title)) {
NotificationSettingsActivity.start(this);
}
}
@ -187,14 +196,14 @@ public class SettingsPageActivity extends AppCompatActivity {
}
private void refreshItems() {
if (PAGE_CLEAR_CACHE.equals(currentPage)) {
if (PAGE_CLEAR_CACHE.equals(page)) {
// 异步加载缓存大小
updateCacheSize();
} else if (PAGE_ABOUT.equals(currentPage)) {
} else if (PAGE_ABOUT.equals(page)) {
// 更新版本信息
updateVersionInfo();
} else {
adapter.submitList(buildItems(currentPage));
adapter.submitList(buildItems(page));
}
}
@ -278,6 +287,7 @@ public class SettingsPageActivity extends AppCompatActivity {
if (PAGE_NOTIFICATIONS.equals(page)) {
list.add(MoreItem.section("消息提醒"));
list.add(MoreItem.row("通知设置", "管理通知开关和免打扰", R.drawable.ic_notifications_24));
list.add(MoreItem.row("系统通知", "关注、评论、私信提醒", R.drawable.ic_notifications_24));
list.add(MoreItem.row("免打扰", "设置勿扰时段", R.drawable.ic_notifications_24));
return list;
@ -466,4 +476,281 @@ public class SettingsPageActivity extends AppCompatActivity {
.setNegativeButton("取消", null)
.show();
}
private void showChangePasswordDialog() {
EditText oldPassword = new EditText(this);
oldPassword.setHint("请输入旧密码");
oldPassword.setInputType(android.text.InputType.TYPE_CLASS_TEXT | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD);
EditText newPassword = new EditText(this);
newPassword.setHint("请输入新密码");
newPassword.setInputType(android.text.InputType.TYPE_CLASS_TEXT | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD);
android.widget.LinearLayout layout = new android.widget.LinearLayout(this);
layout.setOrientation(android.widget.LinearLayout.VERTICAL);
layout.setPadding(dp(24), dp(16), dp(24), dp(8));
layout.addView(oldPassword);
layout.addView(newPassword);
new AlertDialog.Builder(this)
.setTitle("修改密码")
.setView(layout)
.setPositiveButton("确定", (d, w) -> {
Toast.makeText(this, "密码修改功能待实现", Toast.LENGTH_SHORT).show();
})
.setNegativeButton("取消", null)
.show();
}
private void showBindPhoneDialog() {
EditText phoneInput = new EditText(this);
phoneInput.setHint("请输入手机号");
phoneInput.setInputType(android.text.InputType.TYPE_CLASS_PHONE);
new AlertDialog.Builder(this)
.setTitle("绑定手机号")
.setView(phoneInput)
.setPositiveButton("确定", (d, w) -> {
Toast.makeText(this, "手机号绑定功能待实现", Toast.LENGTH_SHORT).show();
})
.setNegativeButton("取消", null)
.show();
}
private void showDeviceManagementDialog() {
new AlertDialog.Builder(this)
.setTitle("登录设备管理")
.setMessage("当前登录设备:\n• 本设备(当前)\n\n功能开发中...")
.setPositiveButton("确定", null)
.show();
}
private void showBlacklistDialog() {
new AlertDialog.Builder(this)
.setTitle("黑名单管理")
.setMessage("黑名单功能允许您屏蔽不想看到的用户。\n\n" +
"功能说明:\n" +
"• 被加入黑名单的用户无法给您发送消息\n" +
"• 您将不会看到被屏蔽用户的直播和动态\n" +
"• 可以随时解除屏蔽\n\n" +
"注意:此功能需要登录后使用,目前功能正在开发中,敬请期待。")
.setPositiveButton("确定", null)
.show();
}
private void showPermissionManagementDialog() {
new AlertDialog.Builder(this)
.setTitle("权限管理")
.setMessage("应用需要以下权限才能正常工作:\n\n" +
"• 相机:用于直播和拍照\n" +
"• 麦克风:用于直播和语音聊天\n" +
"• 位置:用于附近直播和发现功能\n" +
"• 存储:用于保存图片和视频\n\n" +
"点击确定将跳转到系统设置页面,您可以在那里管理应用权限。")
.setPositiveButton("前往设置", (d, w) -> {
try {
Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(android.net.Uri.parse("package:" + getPackageName()));
startActivity(intent);
} catch (Exception e) {
Toast.makeText(this, "无法打开设置页面", Toast.LENGTH_SHORT).show();
}
})
.setNegativeButton("取消", null)
.show();
}
private void showPrivacyPolicyDialog() {
String privacyPolicy = "隐私政策\n\n" +
"我们非常重视您的隐私保护。本隐私政策说明了我们如何收集、使用和保护您的个人信息。\n\n" +
"1. 信息收集\n" +
"我们可能收集以下信息:\n" +
"• 账户信息:昵称、头像、个人资料\n" +
"• 设备信息:设备型号、操作系统版本\n" +
"• 使用信息:观看记录、互动记录\n" +
"• 位置信息:仅在您授权时收集\n\n" +
"2. 信息使用\n" +
"我们使用收集的信息用于:\n" +
"• 提供和改进服务\n" +
"• 个性化推荐\n" +
"• 保障账户安全\n\n" +
"3. 信息保护\n" +
"我们采用行业标准的安全措施保护您的信息,不会向第三方出售您的个人信息。\n\n" +
"4. 您的权利\n" +
"您可以随时访问、修改或删除您的个人信息。\n\n" +
"如有疑问,请联系客服。";
new AlertDialog.Builder(this)
.setTitle("隐私政策")
.setMessage(privacyPolicy)
.setPositiveButton("确定", null)
.show();
}
private void showClearAllCacheDialog() {
new AlertDialog.Builder(this)
.setTitle("清理缓存")
.setMessage("确定要清理所有缓存吗?")
.setPositiveButton("确定", (d, w) -> {
CacheManager.clearAllCache(this, new CacheManager.OnCacheClearListener() {
@Override
public void onSuccess(long clearedSize) {
runOnUiThread(() -> {
refreshItems();
Toast.makeText(SettingsPageActivity.this, "缓存已清理", Toast.LENGTH_SHORT).show();
});
}
@Override
public void onError(Exception e) {
runOnUiThread(() -> {
Toast.makeText(SettingsPageActivity.this, "清理缓存失败", Toast.LENGTH_SHORT).show();
});
}
});
})
.setNegativeButton("取消", null)
.show();
}
private void showClearImageCacheDialog() {
new AlertDialog.Builder(this)
.setTitle("清理图片缓存")
.setMessage("确定要清理图片缓存吗?")
.setPositiveButton("确定", (d, w) -> {
CacheManager.clearImageCache(this, new CacheManager.OnCacheClearListener() {
@Override
public void onSuccess(long clearedSize) {
runOnUiThread(() -> {
refreshItems();
Toast.makeText(SettingsPageActivity.this, "图片缓存已清理", Toast.LENGTH_SHORT).show();
});
}
@Override
public void onError(Exception e) {
runOnUiThread(() -> {
Toast.makeText(SettingsPageActivity.this, "清理图片缓存失败", Toast.LENGTH_SHORT).show();
});
}
});
})
.setNegativeButton("取消", null)
.show();
}
private void showFAQDialog() {
String faq = "常见问题\n\n" +
"Q1: 如何开始直播?\n" +
"A: 在首页点击\"创建直播间\"按钮,输入房间信息后即可开始直播。\n\n" +
"Q2: 如何关注主播?\n" +
"A: 在直播间或主播个人主页点击\"关注\"按钮即可。\n\n" +
"Q3: 如何发送消息?\n" +
"A: 在直播间底部输入框输入消息后发送,或在消息页面与好友聊天。\n\n" +
"Q4: 如何修改个人资料?\n" +
"A: 进入个人中心,点击\"编辑资料\"即可修改昵称、签名等信息。\n\n" +
"Q5: 如何清理缓存?\n" +
"A: 在设置页面选择\"清理缓存\",可以清理应用缓存和图片缓存。\n\n" +
"Q6: 忘记密码怎么办?\n" +
"A: 请联系客服或通过绑定的手机号找回密码。\n\n" +
"Q7: 如何举报不良内容?\n" +
"A: 在直播间或用户主页可以举报违规内容,我们会及时处理。\n\n" +
"更多问题请联系客服。";
new AlertDialog.Builder(this)
.setTitle("常见问题")
.setMessage(faq)
.setPositiveButton("确定", null)
.show();
}
private void showFeedbackDialog() {
EditText feedbackInput = new EditText(this);
feedbackInput.setHint("请输入您的意见或建议");
feedbackInput.setMinLines(5);
new AlertDialog.Builder(this)
.setTitle("意见反馈")
.setView(feedbackInput)
.setPositiveButton("提交", (d, w) -> {
Toast.makeText(this, "反馈提交功能待实现", Toast.LENGTH_SHORT).show();
})
.setNegativeButton("取消", null)
.show();
}
private void showCustomerServiceDialog() {
String customerService = "联系客服\n\n" +
"我们提供多种客服联系方式:\n\n" +
"📞 客服电话:\n" +
"400-XXX-XXXX\n" +
"服务时间9:00-22:00\n\n" +
"💬 在线客服:\n" +
"在应用内消息页面联系客服\n\n" +
"📧 邮箱:\n" +
"support@livestreaming.com\n\n" +
"🕐 服务时间:\n" +
"周一至周日 9:00-22:00\n\n" +
"我们会尽快回复您的问题。";
new AlertDialog.Builder(this)
.setTitle("联系客服")
.setMessage(customerService)
.setPositiveButton("确定", null)
.show();
}
private void showVersionInfoDialog() {
try {
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
String versionName = packageInfo.versionName;
int versionCode = packageInfo.versionCode;
String message = "Live Streaming\n版本" + versionName + "\n构建号" + versionCode;
new AlertDialog.Builder(this)
.setTitle("版本信息")
.setMessage(message)
.setPositiveButton("确定", null)
.show();
} catch (PackageManager.NameNotFoundException e) {
new AlertDialog.Builder(this)
.setTitle("版本信息")
.setMessage("Live Streaming 1.0")
.setPositiveButton("确定", null)
.show();
}
}
private void showUserAgreementDialog() {
String userAgreement = "用户协议\n\n" +
"欢迎使用直播应用!使用本应用即表示您同意遵守以下条款:\n\n" +
"1. 服务条款\n" +
"• 您必须年满18周岁才能使用本服务\n" +
"• 您需要对自己的账户安全负责\n" +
"• 禁止发布违法违规内容\n\n" +
"2. 用户行为规范\n" +
"• 禁止发布色情、暴力、赌博等违法内容\n" +
"• 禁止骚扰、辱骂其他用户\n" +
"• 禁止传播虚假信息\n" +
"• 禁止进行任何形式的欺诈行为\n\n" +
"3. 知识产权\n" +
"• 应用内的所有内容受知识产权法保护\n" +
"• 未经授权不得复制、传播\n\n" +
"4. 免责声明\n" +
"• 用户发布的内容不代表本平台观点\n" +
"• 平台不对用户行为承担责任\n\n" +
"5. 服务变更\n" +
"我们保留随时修改或终止服务的权利。\n\n" +
"如有疑问,请联系客服。";
new AlertDialog.Builder(this)
.setTitle("用户协议")
.setMessage(userAgreement)
.setPositiveButton("确定", null)
.show();
}
private int dp(int value) {
return (int) (value * getResources().getDisplayMetrics().density);
}
}

View File

@ -13,6 +13,7 @@ import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.FileProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
@ -453,10 +454,27 @@ public class TabPlaceholderActivity extends AppCompatActivity {
}
if (!TextUtils.isEmpty(avatarUri)) {
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(
this,
getPackageName() + ".fileprovider",
file
);
}
} catch (Exception e) {
// 如果转换失败使用原始 URI
}
}
Glide.with(this)
.load(Uri.parse(avatarUri))
.load(uri)
.circleCrop()
.error(R.drawable.ic_account_circle_24)
.placeholder(R.drawable.ic_account_circle_24)
.into(binding.moreAvatar);
} else if (avatarRes != 0) {
binding.moreAvatar.setImageResource(avatarRes);

View File

@ -32,6 +32,10 @@ public class TableGamesActivity extends AppCompatActivity {
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNavigation);
bottomNavigation.setOnItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.nav_home) {
@ -57,5 +61,16 @@ public class TableGamesActivity extends AppCompatActivity {
return true;
});
}
@Override
protected void onResume() {
super.onResume();
if (binding != null) {
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
bottomNav.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNav);
}
}
}

View File

@ -71,6 +71,13 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
boolean isFriend = isFriend(userId);
updateAddFriendButton(isFriend);
// TODO: 接入后端接口 - 发送好友请求
// 接口路径: POST /api/friends/request
// 请求参数:
// - targetUserId: 目标用户ID
// - userId: 当前用户ID从token中获取
// 返回数据格式: ApiResponse<{success: boolean, message: string}>
// 发送成功后更新按钮状态为"已发送""已添加"
binding.addFriendButton.setOnClickListener(v -> {
if (TextUtils.isEmpty(userId)) {
Toast.makeText(this, "用户信息缺失", Toast.LENGTH_SHORT).show();
@ -133,6 +140,21 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
}
private void bindDemoStatsAndWorks(String userId) {
// TODO: 接入后端接口 - 获取其他用户资料和统计数据
// 接口路径: GET /api/users/{userId}/profile
// 请求参数:
// - userId: 用户ID路径参数
// 返回数据格式: ApiResponse<UserProfile>
// UserProfile对象应包含: id, name, avatarUrl, bio, location, worksCount, followingCount,
// followersCount, likesCount等字段
// TODO: 接入后端接口 - 获取用户作品列表
// 接口路径: GET /api/users/{userId}/works
// 请求参数:
// - userId: 用户ID路径参数
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
// WorkItem对象应包含: id, coverUrl, title, likeCount, viewCount等字段
if (binding == null) return;
String seed = !TextUtils.isEmpty(userId) ? userId : "demo";

View File

@ -33,6 +33,10 @@ public class VoiceMatchActivity extends AppCompatActivity {
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNavigation);
bottomNavigation.setOnItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.nav_home) {
@ -58,5 +62,16 @@ public class VoiceMatchActivity extends AppCompatActivity {
return true;
});
}
@Override
protected void onResume() {
super.onResume();
if (binding != null) {
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
bottomNav.setSelectedItemId(R.id.nav_friends);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNav);
}
}
}

View File

@ -37,6 +37,15 @@ public class WatchHistoryActivity extends AppCompatActivity {
startActivity(intent);
});
// TODO: 接入后端接口 - 获取观看历史
// 接口路径: GET /api/watch/history
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<Room>>
// Room对象应包含: id, title, streamerName, type, isLive, coverUrl, watchTime观看时间等字段
// 列表应按观看时间倒序排列最近观看的在前面
StaggeredGridLayoutManager glm = new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL);
glm.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS);
binding.roomsRecyclerView.setLayoutManager(glm);

View File

@ -1 +1,145 @@
package com.example.livestreaming; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import androidx.appcompat.app.AppCompatActivity; import com.example.livestreaming.databinding.ActivityWishTreeBinding; import com.google.android.material.bottomnavigation.BottomNavigationView; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; import java.util.Locale; import java.util.TimeZone; public class WishTreeActivity extends AppCompatActivity { private ActivityWishTreeBinding binding; private final Handler handler = new Handler(Looper.getMainLooper()); private Runnable timerRunnable; public static void start(Context context) { Intent intent = new Intent(context, WishTreeActivity.class); context.startActivity(intent); } @Override protected void onStart() { super.onStart(); startBannerCountdown(); } @Override protected void onStop() { super.onStop(); stopBannerCountdown(); } private void startBannerCountdown() { stopBannerCountdown(); timerRunnable = new Runnable() { @Override public void run() { updateBannerTimer(); handler.postDelayed(this, 1000); } }; handler.post(timerRunnable); } private void stopBannerCountdown() { if (timerRunnable != null) { handler.removeCallbacks(timerRunnable); timerRunnable = null; } } private void updateBannerTimer() { if (binding == null) return; try { long now = System.currentTimeMillis(); TimeZone tz = TimeZone.getDefault(); Calendar c = Calendar.getInstance(tz); c.setTimeInMillis(now); c.set(Calendar.HOUR_OF_DAY, 0); c.set(Calendar.MINUTE, 0); c.set(Calendar.SECOND, 0); c.set(Calendar.MILLISECOND, 0); c.add(Calendar.DAY_OF_MONTH, 1); long nextMidnight = c.getTimeInMillis(); long diff = Math.max(0, nextMidnight - now); long totalSeconds = diff / 1000; long hours = totalSeconds / 3600; long minutes = (totalSeconds % 3600) / 60; long seconds = totalSeconds % 60; SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); fmt.setTimeZone(tz); String current = fmt.format(new Date(now)); String remain = String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds); binding.bannerTimer.setText(current + " " + remain); } catch (Exception ignored) { } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); binding = ActivityWishTreeBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); startBannerCountdown(); BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation; bottomNavigation.setSelectedItemId(R.id.nav_wish_tree); bottomNavigation.setOnItemSelectedListener(item -> { int id = item.getItemId(); if (id == R.id.nav_wish_tree) { return true; } if (id == R.id.nav_home) { startActivity(new Intent(this, MainActivity.class)); finish(); return true; } if (id == R.id.nav_friends) { startActivity(new Intent(this, FishPondActivity.class)); finish(); return true; } if (id == R.id.nav_messages) { MessagesActivity.start(this); finish(); return true; } if (id == R.id.nav_profile) { ProfileActivity.start(this); finish(); return true; } return true; }); } @Override protected void onResume() { super.onResume(); if (binding != null) { binding.bottomNavInclude.bottomNavigation.setSelectedItemId(R.id.nav_wish_tree); } } }
package com.example.livestreaming;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.appcompat.app.AppCompatActivity;
import com.example.livestreaming.databinding.ActivityWishTreeBinding;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
public class WishTreeActivity extends AppCompatActivity {
private ActivityWishTreeBinding binding;
private final Handler handler = new Handler(Looper.getMainLooper());
private Runnable timerRunnable;
public static void start(Context context) {
Intent intent = new Intent(context, WishTreeActivity.class);
context.startActivity(intent);
}
@Override
protected void onStart() {
super.onStart();
startBannerCountdown();
}
@Override
protected void onStop() {
super.onStop();
stopBannerCountdown();
}
private void startBannerCountdown() {
stopBannerCountdown();
timerRunnable = new Runnable() {
@Override
public void run() {
updateBannerTimer();
handler.postDelayed(this, 1000);
}
};
handler.post(timerRunnable);
}
private void stopBannerCountdown() {
if (timerRunnable != null) {
handler.removeCallbacks(timerRunnable);
timerRunnable = null;
}
}
private void updateBannerTimer() {
if (binding == null) return;
try {
long now = System.currentTimeMillis();
TimeZone tz = TimeZone.getDefault();
Calendar c = Calendar.getInstance(tz);
c.setTimeInMillis(now);
c.set(Calendar.HOUR_OF_DAY, 0);
c.set(Calendar.MINUTE, 0);
c.set(Calendar.SECOND, 0);
c.set(Calendar.MILLISECOND, 0);
c.add(Calendar.DAY_OF_MONTH, 1);
long nextMidnight = c.getTimeInMillis();
long diff = Math.max(0, nextMidnight - now);
long totalSeconds = diff / 1000;
long hours = totalSeconds / 3600;
long minutes = (totalSeconds % 3600) / 60;
long seconds = totalSeconds % 60;
SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
fmt.setTimeZone(tz);
String current = fmt.format(new Date(now));
String remain = String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds);
binding.bannerTimer.setText(current + " " + remain);
} catch (Exception ignored) {
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityWishTreeBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
startBannerCountdown();
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_wish_tree);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNavigation);
bottomNavigation.setOnItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.nav_wish_tree) {
return true;
}
if (id == R.id.nav_home) {
startActivity(new Intent(this, MainActivity.class));
finish();
return true;
}
if (id == R.id.nav_friends) {
startActivity(new Intent(this, FishPondActivity.class));
finish();
return true;
}
if (id == R.id.nav_messages) {
MessagesActivity.start(this);
finish();
return true;
}
if (id == R.id.nav_profile) {
ProfileActivity.start(this);
finish();
return true;
}
return true;
});
}
@Override
protected void onResume() {
super.onResume();
if (binding != null) {
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
bottomNav.setSelectedItemId(R.id.nav_wish_tree);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNav);
}
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FF4444" />
</shape>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#AAAAAA"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
</vector>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- 第一个勾 -->
<path
android:fillColor="#4CAF50"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
<!-- 第二个勾(稍微偏移) -->
<path
android:fillColor="#4CAF50"
android:pathData="M11,16.17L6.83,12l-1.42,1.41L11,19 23,7l-1.41,-1.41z" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M18,16.08c-0.7,0 -1.3,-0.3 -1.8,-0.8l-6.2,-3.6c0.1,-0.3 0.2,-0.6 0.2,-1 0,-0.4 -0.1,-0.7 -0.2,-1l6.2,-3.6c0.5,0.5 1.1,0.8 1.8,0.8 1.4,0 2.5,-1.1 2.5,-2.5s-1.1,-2.5 -2.5,-2.5 -2.5,1.1 -2.5,2.5c0,0.4 0.1,0.7 0.2,1l-6.2,3.6c-0.5,-0.5 -1.1,-0.8 -1.8,-0.8 -1.4,0 -2.5,1.1 -2.5,2.5s1.1,2.5 2.5,2.5c0.7,0 1.3,-0.3 1.8,-0.8l6.2,3.6c-0.1,0.3 -0.2,0.6 -0.2,1 0,1.4 1.1,2.5 2.5,2.5s2.5,-1.1 2.5,-2.5 -1.1,-2.5 -2.5,-2.5z"/>
</vector>

View File

@ -32,6 +32,42 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/searchInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="搜索会话或消息"
app:boxBackgroundMode="outline"
app:boxCornerRadiusBottomEnd="20dp"
app:boxCornerRadiusBottomStart="20dp"
app:boxCornerRadiusTopEnd="20dp"
app:boxCornerRadiusTopStart="20dp"
app:boxStrokeWidth="0dp"
app:boxStrokeWidthFocused="0dp"
app:hintTextColor="#AAAAAA"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/searchInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:imeOptions="actionSearch"
android:inputType="text"
android:maxLines="1"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:textColor="#111111"
android:textColorHint="#AAAAAA"
android:textSize="14sp" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.AppBarLayout>

View File

@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/white">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="14dp"
android:paddingBottom="14dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="返回"
android:src="@drawable/ic_arrow_back_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/titleText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:ellipsize="end"
android:maxLines="1"
android:text="通知"
android:textColor="#111111"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/backButton"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- 分类标签 -->
<com.google.android.material.tabs.TabLayout
android:id="@+id/categoryTabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/white"
app:tabIndicatorColor="@color/purple_500"
app:tabIndicatorFullWidth="false"
app:tabMode="scrollable"
app:tabSelectedTextColor="#111111"
app:tabTextColor="#666666"
app:tabPaddingStart="16dp"
app:tabPaddingEnd="16dp">
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="全部" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="系统" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="互动" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="关注" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="私信" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="直播" />
</com.google.android.material.tabs.TabLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/notificationsRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="16dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<com.example.livestreaming.EmptyStateView
android:id="@+id/emptyStateView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -217,6 +217,16 @@
</LinearLayout>
<ImageButton
android:id="@+id/shareButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="分享"
android:src="@drawable/ic_share_24"
android:tint="#666666" />
<com.google.android.material.button.MaterialButton
android:id="@+id/followButton"
style="@style/Widget.Material3.Button.TonalButton"

View File

@ -59,16 +59,6 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/title" />
<ImageView
android:id="@+id/muteIcon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginEnd="8dp"
android:contentDescription="mute"
android:src="@android:drawable/ic_lock_silent_mode"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/lastMessage"
app:layout_constraintEnd_toStartOf="@id/unreadBadge" />
<TextView
android:id="@+id/unreadBadge"

View File

@ -47,17 +47,35 @@
app:layout_constraintHorizontal_bias="1"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/timeText"
<LinearLayout
android:id="@+id/timeStatusLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:includeFontPadding="false"
android:text="12:31"
android:textColor="#AAAAAA"
android:textSize="11sp"
android:visibility="gone"
android:layout_marginStart="4dp"
android:orientation="horizontal"
android:gravity="center_vertical"
app:layout_constraintEnd_toEndOf="@id/messageText"
app:layout_constraintTop_toBottomOf="@id/messageText" />
app:layout_constraintTop_toBottomOf="@id/messageText">
<TextView
android:id="@+id/timeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="12:31"
android:textColor="#AAAAAA"
android:textSize="11sp"
android:visibility="gone" />
<ImageView
android:id="@+id/statusIcon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="4dp"
android:visibility="gone"
android:contentDescription="消息状态" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/white"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:minHeight="72dp">
<ImageView
android:id="@+id/avatar"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/bg_avatar_circle_transparent"
android:clipToOutline="true"
android:scaleType="centerCrop"
android:src="@drawable/ic_notifications_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 未读标记 -->
<View
android:id="@+id/unreadDot"
android:layout_width="8dp"
android:layout_height="8dp"
android:background="@drawable/bg_circle_red"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="@id/avatar" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:text="通知标题"
android:textColor="#111111"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/timeText"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="@id/avatar"
android:layout_marginStart="12dp"
android:layout_marginEnd="10dp" />
<TextView
android:id="@+id/content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:text="通知内容预览"
android:textColor="#666666"
android:textSize="13sp"
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/title"
android:layout_marginTop="4dp" />
<TextView
android:id="@+id/timeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="12:30"
android:textColor="#999999"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/title" />
<View
android:id="@+id/divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="56dp"
android:background="#0F000000"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/avatar" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="cache_images" path="images/" />
<files-path name="avatars" path="avatars/" />
</paths>

View File

@ -534,24 +534,30 @@
**预计工作量**: 2-3天
#### 9. **消息功能完善(前端)** ⭐⭐ ✅ 已完成
#### 9. **消息功能完善(前端)** ⭐⭐ ⚠️ 待实现
**为什么重要**: 完善核心社交功能
- [x] 完善消息列表UI
- [x] 实现消息状态显示(发送中、已发送、已读)
- [x] 优化消息列表性能
- [x] 添加消息操作(复制、删除等)
- [x] 实现消息搜索(本地)
- [x] 完善消息列表UI(会话列表)
- [ ] 实现消息状态显示(发送中、已发送、已读)
- [ ] 优化消息列表性能使用messageId
- [ ] 添加消息操作(复制、删除等)
- [ ] 实现消息搜索(本地)
**完成内容**:
- 为ChatMessage添加了MessageStatus枚举发送中、已发送、已读
- 在发送的消息气泡旁显示状态图标(时钟、单勾、双勾)
- 实现了消息长按菜单,支持复制和删除操作
- 优化了DiffUtil使用messageId作为唯一标识提升列表更新性能
- 在MessagesActivity中添加了搜索功能支持按会话标题和消息内容搜索
- 优化了消息列表UI包括消息预览处理图片/语音消息)、时间显示、布局优化
- 添加了内存泄漏防护onDestroy中清理延迟任务
- 优化了滚动行为使用smoothScrollToPosition
**已完成内容**:
- ✅ 会话列表展示MessagesActivity
- ✅ 滑动删除/标记已读功能MessagesActivity
- ✅ 消息发送和列表展示ConversationActivity
- ✅ 未读消息徽章管理
**待实现内容**:
- ⚠️ 为ChatMessage添加MessageStatus枚举发送中、已发送、已读
- ⚠️ 在发送的消息气泡旁显示状态图标(时钟、单勾、双勾)
- ⚠️ 实现消息长按菜单,支持复制和删除操作
- ⚠️ 优化DiffUtil使用messageId作为唯一标识提升列表更新性能当前使用timestamp
- ⚠️ 在MessagesActivity中添加搜索功能支持按会话标题和消息内容搜索
- ⚠️ 优化消息列表UI包括消息预览处理图片/语音消息)、时间显示、布局优化
- ⚠️ 添加内存泄漏防护onDestroy中清理延迟任务
- ⚠️ 优化滚动行为使用smoothScrollToPosition替代scrollToPosition
**预计工作量**: 3-4天