diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index e942336f..2551d5b4 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -60,12 +60,6 @@ android { ) } } - - // 编译优化 - dexOptions { - javaMaxHeapSize = "2g" - preDexLibraries = true - } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 82a6be50..c69d4ad9 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -44,6 +44,10 @@ android:name="com.example.livestreaming.NotificationSettingsActivity" android:exported="false" /> + + diff --git a/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java b/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java index 5278445a..7c597827 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java @@ -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; + } } \ No newline at end of file diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java index e7676a30..b01d4106 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java @@ -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 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); } @@ -68,19 +86,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> + // 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对象应包含: 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; } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java index b611abea..40c2010a 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java @@ -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 { + 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 { + 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 { + 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 DIFF = new DiffUtil.ItemCallback() { @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()); } }; } diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationsAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationsAdapter.java index c9f18959..afaea9cb 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationsAdapter.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationsAdapter.java @@ -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 { 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); + } + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/EditProfileActivity.java b/android-app/app/src/main/java/com/example/livestreaming/EditProfileActivity.java index f227a942..ef0a6e97 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/EditProfileActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/EditProfileActivity.java @@ -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; diff --git a/android-app/app/src/main/java/com/example/livestreaming/FansListActivity.java b/android-app/app/src/main/java/com/example/livestreaming/FansListActivity.java index 38db12e3..80998869 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/FansListActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/FansListActivity.java @@ -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> + // User对象应包含: id, name, avatarUrl, bio, isLive, followTime等字段 + // 列表应按关注时间倒序排列(最新关注的在前) binding.fansRecyclerView.setLayoutManager(new LinearLayoutManager(this)); binding.fansRecyclerView.setAdapter(adapter); adapter.submitList(buildDemoFans()); diff --git a/android-app/app/src/main/java/com/example/livestreaming/FindGameActivity.java b/android-app/app/src/main/java/com/example/livestreaming/FindGameActivity.java index 2b7e09e1..8c499787 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/FindGameActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/FindGameActivity.java @@ -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); + } + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/FishPondActivity.java b/android-app/app/src/main/java/com/example/livestreaming/FishPondActivity.java index a3ff27a8..dacebec9 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/FishPondActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/FishPondActivity.java @@ -320,6 +320,16 @@ public class FishPondActivity extends AppCompatActivity { } private void refreshUsers() { + // TODO: 接入后端接口 - 获取附近用户(缘池功能) + // 接口路径: GET /api/users/nearby + // 请求参数: + // - latitude: 当前用户纬度(必填) + // - longitude: 当前用户经度(必填) + // - radius (可选): 搜索半径(单位:米,默认5000) + // - limit (可选): 返回数量,默认6 + // 返回数据格式: ApiResponse> + // User对象应包含: id, name, avatarUrl, location, bio, distance(距离,单位:米), isLive等字段 + // 返回距离最近的N个用户,用于显示在轨道上 if (binding == null) return; int[] avatars = new int[] { diff --git a/android-app/app/src/main/java/com/example/livestreaming/FollowingListActivity.java b/android-app/app/src/main/java/com/example/livestreaming/FollowingListActivity.java index c065f270..83f109e7 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/FollowingListActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/FollowingListActivity.java @@ -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> + // User对象应包含: id, name, avatarUrl, bio, isLive, lastLiveTime, followTime等字段 + // 列表应按关注时间倒序或最后直播时间倒序排列 binding.followingRecyclerView.setLayoutManager(new LinearLayoutManager(this)); binding.followingRecyclerView.setAdapter(adapter); adapter.submitList(buildDemoFollowing()); diff --git a/android-app/app/src/main/java/com/example/livestreaming/HeartbeatSignalActivity.java b/android-app/app/src/main/java/com/example/livestreaming/HeartbeatSignalActivity.java index fbe1e118..e3e2cedf 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/HeartbeatSignalActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/HeartbeatSignalActivity.java @@ -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); + } + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/KTVTogetherActivity.java b/android-app/app/src/main/java/com/example/livestreaming/KTVTogetherActivity.java index 1765b46b..129e9b9b 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/KTVTogetherActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/KTVTogetherActivity.java @@ -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); + } + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/LikesListActivity.java b/android-app/app/src/main/java/com/example/livestreaming/LikesListActivity.java index 0a8df165..9340ae01 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/LikesListActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/LikesListActivity.java @@ -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> + // LikeItem对象应包含: id, userId, username, avatarUrl, targetType (room/work), targetId, targetTitle, likeTime等字段 + // 列表应按点赞时间倒序排列(最新点赞的在前面) binding.likesRecyclerView.setLayoutManager(new LinearLayoutManager(this)); binding.likesRecyclerView.setAdapter(adapter); adapter.submitList(buildDemoLikes()); diff --git a/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java b/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java index db4879a8..88a43bb3 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java +++ b/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java @@ -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); } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/LocalNotificationManager.java b/android-app/app/src/main/java/com/example/livestreaming/LocalNotificationManager.java new file mode 100644 index 00000000..ddfa9d18 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/LocalNotificationManager.java @@ -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; + } +} + diff --git a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java index 4d77362b..dd525d04 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java @@ -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 或 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对象应包含: id, title, streamerName, streamKey, streamUrls (包含rtmp, flv, hls地址)等字段 + ApiClient.getService(getApplicationContext()).createRoom(new CreateRoomRequest(title, streamerName, "live")) .enqueue(new Callback>() { @Override public void onResponse(Call> call, Response> response) { @@ -933,6 +987,14 @@ public class MainActivity extends AppCompatActivity { } private void fetchRooms() { + // TODO: 接入后端接口 - 获取房间列表 + // 接口路径: GET /api/rooms + // 请求参数: + // - category (可选): 分类筛选,如"游戏"、"才艺"等 + // - page (可选): 页码,用于分页加载 + // - pageSize (可选): 每页数量 + // 返回数据格式: ApiResponse> + // 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 sourceRooms; + if ("关注".equals(currentTopTab)) { + sourceRooms = followRooms; + } else if ("附近".equals(currentTopTab)) { + // 附近页面不显示房间,不需要搜索筛选 + return; + } else { + // 发现页面 + sourceRooms = discoverRooms.isEmpty() ? allRooms : discoverRooms; + } + + // 只根据搜索文本筛选(按房间标题和主播名称),不考虑分类 + List 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 buildFollowRooms() { + // TODO: 接入后端接口 - 获取关注主播的直播间列表 + // 接口路径: GET /api/following/rooms 或 GET /api/rooms?type=following + // 请求参数: + // - userId: 当前用户ID(从token中获取) + // - page (可选): 页码 + // - pageSize (可选): 每页数量 + // 返回数据格式: ApiResponse> + // Room对象应包含: id, title, streamerName, streamerId, type, isLive, coverUrl, viewerCount等字段 + // 只返回当前用户已关注的主播正在直播的房间 List list = new ArrayList<>(); // 从FollowingListActivity获取已关注的主播列表 @@ -1478,6 +1590,15 @@ public class MainActivity extends AppCompatActivity { * 构建发现页面的房间列表(推荐算法前端实现) */ private List buildDiscoverRooms() { + // TODO: 接入后端接口 - 获取推荐直播间列表 + // 接口路径: GET /api/rooms/recommend 或 GET /api/rooms?type=recommend + // 请求参数: + // - userId: 当前用户ID(从token中获取,用于个性化推荐) + // - page (可选): 页码 + // - pageSize (可选): 每页数量 + // 返回数据格式: ApiResponse> + // Room对象应包含: id, title, streamerName, type, isLive, coverUrl, viewerCount, recommendScore等字段 + // 后端应根据用户观看历史、点赞记录、关注关系等进行个性化推荐 List list = new ArrayList<>(); // 推荐算法:基于观看历史、点赞等模拟数据 @@ -1544,6 +1665,17 @@ public class MainActivity extends AppCompatActivity { * 构建附近页面的用户列表(使用模拟位置数据) */ private List buildNearbyUsers() { + // TODO: 接入后端接口 - 获取附近用户列表 + // 接口路径: GET /api/users/nearby + // 请求参数: + // - latitude: 当前用户纬度(必填) + // - longitude: 当前用户经度(必填) + // - radius (可选): 搜索半径(单位:米,默认5000) + // - page (可选): 页码 + // - pageSize (可选): 每页数量 + // 返回数据格式: ApiResponse> + // NearbyUser对象应包含: id, name, avatarUrl, distance (距离,单位:米), isLive, location等字段 + // 需要先获取用户位置权限,然后调用此接口 List list = new ArrayList<>(); // 模拟位置数据:生成不同距离的用户 diff --git a/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java index 75864a6f..779b9a24 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java @@ -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 conversations = new ArrayList<>(); + private final List allConversations = new ArrayList<>(); // 保存所有会话,用于搜索 + private final List 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); - - // 用户点击会话时,减少该会话的未读数量 - if (item.getUnreadCount() > 0) { - // 更新该会话的未读数量为0(在实际应用中,这里应该更新数据源) - // 然后更新总未读数量 - UnreadMessageManager.decrementUnreadCount(this, item.getUnreadCount()); - // 更新列表中的未读数量显示 - updateConversationUnreadCount(item.getId(), 0); + 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); + } + } 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> + // 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)); // 检查是否需要显示空状态 @@ -134,6 +161,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> + // 搜索范围包括:会话标题、最后一条消息内容、对方用户名等 + 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) { // 两个按钮的总宽度:删除按钮 + 标记已读按钮 @@ -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(); + + // 如果有展开的项,且点击的不是展开的项,恢复展开的项 + 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 (swipedPosition == RecyclerView.NO_POSITION || (deleteButtonRect == null && markReadButtonRect == null)) { + return false; // 不拦截,让事件正常传递 + } - int action = event.getActionMasked(); - if (action == MotionEvent.ACTION_DOWN) { - touchDownX = event.getX(); - touchDownY = event.getY(); - touchIsClick = true; - 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(event.getX() - touchDownX); - float dy = Math.abs(event.getY() - touchDownY); - if (dx > touchSlop || dy > touchSlop) { - touchIsClick = 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) { diff --git a/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java index 84c4e5fb..875d6441 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java @@ -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> + // FriendItem对象应包含: id, name, avatarUrl, subtitle, isOnline, lastOnlineTime等字段 + // 列表应按最后在线时间倒序或添加时间倒序排列 all.clear(); all.addAll(buildDemoFriends()); adapter.submitList(new ArrayList<>(all)); diff --git a/android-app/app/src/main/java/com/example/livestreaming/NotificationItem.java b/android-app/app/src/main/java/com/example/livestreaming/NotificationItem.java new file mode 100644 index 00000000..6afce8b4 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/NotificationItem.java @@ -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; + } +} + diff --git a/android-app/app/src/main/java/com/example/livestreaming/NotificationsActivity.java b/android-app/app/src/main/java/com/example/livestreaming/NotificationsActivity.java new file mode 100644 index 00000000..874f1182 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/NotificationsActivity.java @@ -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 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/live),null表示全部 + // - page (可选): 页码 + // - pageSize (可选): 每页数量 + // 返回数据格式: ApiResponse> + // 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 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 ""; + } + } +} + diff --git a/android-app/app/src/main/java/com/example/livestreaming/NotificationsAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/NotificationsAdapter.java new file mode 100644 index 00000000..0ab98840 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/NotificationsAdapter.java @@ -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 { + + 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 DIFF = new DiffUtil.ItemCallback() { + @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(); + } + }; +} + diff --git a/android-app/app/src/main/java/com/example/livestreaming/OnlineDatingActivity.java b/android-app/app/src/main/java/com/example/livestreaming/OnlineDatingActivity.java index 3ed05067..9ffe696c 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/OnlineDatingActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/OnlineDatingActivity.java @@ -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); + } + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/PeaceEliteActivity.java b/android-app/app/src/main/java/com/example/livestreaming/PeaceEliteActivity.java index 77f4b6a1..60250ac9 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/PeaceEliteActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/PeaceEliteActivity.java @@ -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); + } + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java index ac66bba0..c10b75a9 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java @@ -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对象应包含: 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 + // 更新成功后,同步更新本地缓存和界面显示 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 ""; - } - } - /** * 显示分享个人主页对话框 */ diff --git a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java index 751fd69b..6187189f 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java @@ -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对象应包含: 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> + // - 每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> + // 进入直播间时,先获取最近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对象应包含: 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); diff --git a/android-app/app/src/main/java/com/example/livestreaming/SearchActivity.java b/android-app/app/src/main/java/com/example/livestreaming/SearchActivity.java index f47ffd8e..a03ecbc8 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/SearchActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/SearchActivity.java @@ -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)); diff --git a/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java b/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java index 198623a7..9e004676 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java @@ -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); + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/TabPlaceholderActivity.java b/android-app/app/src/main/java/com/example/livestreaming/TabPlaceholderActivity.java index 84444698..f312625f 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/TabPlaceholderActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/TabPlaceholderActivity.java @@ -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); diff --git a/android-app/app/src/main/java/com/example/livestreaming/TableGamesActivity.java b/android-app/app/src/main/java/com/example/livestreaming/TableGamesActivity.java index 38123d51..338b53dd 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/TableGamesActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/TableGamesActivity.java @@ -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); + } + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/UserProfileReadOnlyActivity.java b/android-app/app/src/main/java/com/example/livestreaming/UserProfileReadOnlyActivity.java index 372cd49f..73546026 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/UserProfileReadOnlyActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/UserProfileReadOnlyActivity.java @@ -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对象应包含: id, name, avatarUrl, bio, location, worksCount, followingCount, + // followersCount, likesCount等字段 + // TODO: 接入后端接口 - 获取用户作品列表 + // 接口路径: GET /api/users/{userId}/works + // 请求参数: + // - userId: 用户ID(路径参数) + // - page (可选): 页码 + // - pageSize (可选): 每页数量 + // 返回数据格式: ApiResponse> + // WorkItem对象应包含: id, coverUrl, title, likeCount, viewCount等字段 if (binding == null) return; String seed = !TextUtils.isEmpty(userId) ? userId : "demo"; diff --git a/android-app/app/src/main/java/com/example/livestreaming/VoiceMatchActivity.java b/android-app/app/src/main/java/com/example/livestreaming/VoiceMatchActivity.java index fb4ff2f8..be003a98 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/VoiceMatchActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/VoiceMatchActivity.java @@ -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); + } + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/WatchHistoryActivity.java b/android-app/app/src/main/java/com/example/livestreaming/WatchHistoryActivity.java index 4b7b4f32..bddb0b9c 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/WatchHistoryActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/WatchHistoryActivity.java @@ -37,6 +37,15 @@ public class WatchHistoryActivity extends AppCompatActivity { startActivity(intent); }); + // TODO: 接入后端接口 - 获取观看历史 + // 接口路径: GET /api/watch/history + // 请求参数: + // - userId: 当前用户ID(从token中获取) + // - page (可选): 页码 + // - pageSize (可选): 每页数量 + // 返回数据格式: ApiResponse> + // 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); diff --git a/android-app/app/src/main/java/com/example/livestreaming/WishTreeActivity.java b/android-app/app/src/main/java/com/example/livestreaming/WishTreeActivity.java index 10dcd6d9..2bc269e3 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/WishTreeActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/WishTreeActivity.java @@ -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); } } } \ No newline at end of file +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); + } + } +} diff --git a/android-app/app/src/main/res/drawable/bg_circle_red.xml b/android-app/app/src/main/res/drawable/bg_circle_red.xml new file mode 100644 index 00000000..63b4037c --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_circle_red.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_check_24.xml b/android-app/app/src/main/res/drawable/ic_check_24.xml new file mode 100644 index 00000000..25824c2f --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_check_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_check_double_24.xml b/android-app/app/src/main/res/drawable/ic_check_double_24.xml new file mode 100644 index 00000000..1db8eb24 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_check_double_24.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_share_24.xml b/android-app/app/src/main/res/drawable/ic_share_24.xml new file mode 100644 index 00000000..4357d10e --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_share_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/layout/activity_messages.xml b/android-app/app/src/main/res/layout/activity_messages.xml index 2cf2b574..b79520e7 100644 --- a/android-app/app/src/main/res/layout/activity_messages.xml +++ b/android-app/app/src/main/res/layout/activity_messages.xml @@ -32,6 +32,42 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_notifications.xml b/android-app/app/src/main/res/layout/activity_notifications.xml new file mode 100644 index 00000000..69a0ec0d --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_notifications.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_room_detail_new.xml b/android-app/app/src/main/res/layout/activity_room_detail_new.xml index 22daa5bd..9e12b5c0 100644 --- a/android-app/app/src/main/res/layout/activity_room_detail_new.xml +++ b/android-app/app/src/main/res/layout/activity_room_detail_new.xml @@ -217,6 +217,16 @@ + + - - + app:layout_constraintTop_toBottomOf="@id/messageText"> + + + + + + diff --git a/android-app/app/src/main/res/layout/item_notification.xml b/android-app/app/src/main/res/layout/item_notification.xml new file mode 100644 index 00000000..bd903884 --- /dev/null +++ b/android-app/app/src/main/res/layout/item_notification.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/xml/file_paths.xml b/android-app/app/src/main/res/xml/file_paths.xml index df12191a..c9dd781f 100644 --- a/android-app/app/src/main/res/xml/file_paths.xml +++ b/android-app/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ + diff --git a/android-app/项目功能完善度分析.md b/android-app/项目功能完善度分析.md index 06b770c8..83d32e86 100644 --- a/android-app/项目功能完善度分析.md +++ b/android-app/项目功能完善度分析.md @@ -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天