diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts
index 979b7d01..b3654bb8 100644
--- a/android-app/app/build.gradle.kts
+++ b/android-app/app/build.gradle.kts
@@ -13,7 +13,8 @@ android {
versionCode = 1
versionName = "1.0"
- buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3001/api/\"")
+ buildConfigField("String", "API_BASE_URL_EMULATOR", "\"http://10.0.2.2:3001/api/\"")
+ buildConfigField("String", "API_BASE_URL_DEVICE", "\"http://192.168.1.100:3001/api/\"")
}
buildTypes {
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index 3a54e02b..2fd4aef6 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -13,6 +13,44 @@
android:hardwareAccelerated="true"
android:largeHeap="true">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/java/com/example/livestreaming/ChatAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/ChatAdapter.java
new file mode 100644
index 00000000..32f59af4
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/ChatAdapter.java
@@ -0,0 +1,76 @@
+package com.example.livestreaming;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.ListAdapter;
+import androidx.recyclerview.widget.RecyclerView;
+
+public class ChatAdapter extends ListAdapter {
+
+ public ChatAdapter() {
+ super(DIFF);
+ }
+
+ private static final DiffUtil.ItemCallback DIFF = new DiffUtil.ItemCallback() {
+ @Override
+ public boolean areItemsTheSame(@NonNull ChatMessage oldItem, @NonNull ChatMessage newItem) {
+ // Chat messages have no stable id; treat position as identity.
+ return oldItem == newItem;
+ }
+
+ @Override
+ public boolean areContentsTheSame(@NonNull ChatMessage oldItem, @NonNull ChatMessage newItem) {
+ String ou = oldItem.getUser();
+ String nu = newItem.getUser();
+ if (ou == null ? nu != null : !ou.equals(nu)) return false;
+ String om = oldItem.getMessage();
+ String nm = newItem.getMessage();
+ if (om == null ? nm != null : !om.equals(nm)) return false;
+ return oldItem.isSystem() == newItem.isSystem();
+ }
+ };
+
+ @NonNull
+ @Override
+ public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_chat_message, parent, false);
+ return new VH(v);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull VH holder, int position) {
+ ChatMessage m = getItem(position);
+ if (m == null) {
+ holder.messageText.setText("");
+ return;
+ }
+
+ if (m.isSystem()) {
+ holder.messageText.setText(m.getMessage() != null ? m.getMessage() : "");
+ holder.messageText.setAlpha(0.8f);
+ } else {
+ String user = m.getUser();
+ String msg = m.getMessage();
+ if (user == null || user.trim().isEmpty()) {
+ holder.messageText.setText(msg != null ? msg : "");
+ } else {
+ holder.messageText.setText(user + ": " + (msg != null ? msg : ""));
+ }
+ holder.messageText.setAlpha(1.0f);
+ }
+ }
+
+ static class VH extends RecyclerView.ViewHolder {
+ final TextView messageText;
+
+ VH(@NonNull View itemView) {
+ super(itemView);
+ messageText = itemView.findViewById(R.id.messageText);
+ }
+ }
+}
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
new file mode 100644
index 00000000..2674a91f
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java
@@ -0,0 +1,32 @@
+package com.example.livestreaming;
+
+public class ChatMessage {
+
+ private final String user;
+ private final String message;
+ private final boolean system;
+
+ public ChatMessage(String systemMessage, boolean system) {
+ this.user = null;
+ this.message = systemMessage;
+ this.system = system;
+ }
+
+ public ChatMessage(String user, String message) {
+ this.user = user;
+ this.message = message;
+ this.system = false;
+ }
+
+ public String getUser() {
+ return user;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public boolean isSystem() {
+ return system;
+ }
+}
diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationItem.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationItem.java
new file mode 100644
index 00000000..7032a306
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationItem.java
@@ -0,0 +1,64 @@
+package com.example.livestreaming;
+
+import java.util.Objects;
+
+public class ConversationItem {
+
+ private final String id;
+ private final String title;
+ private final String lastMessage;
+ private final String timeText;
+ private final int unreadCount;
+ private final boolean muted;
+
+ public ConversationItem(String id, String title, String lastMessage, String timeText, int unreadCount, boolean muted) {
+ this.id = id;
+ this.title = title;
+ this.lastMessage = lastMessage;
+ this.timeText = timeText;
+ this.unreadCount = unreadCount;
+ this.muted = muted;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getLastMessage() {
+ return lastMessage;
+ }
+
+ public String getTimeText() {
+ return timeText;
+ }
+
+ public int getUnreadCount() {
+ return unreadCount;
+ }
+
+ public boolean isMuted() {
+ return muted;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof ConversationItem)) return false;
+ ConversationItem that = (ConversationItem) o;
+ return unreadCount == that.unreadCount
+ && muted == that.muted
+ && Objects.equals(id, that.id)
+ && Objects.equals(title, that.title)
+ && Objects.equals(lastMessage, that.lastMessage)
+ && Objects.equals(timeText, that.timeText);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, title, lastMessage, timeText, unreadCount, muted);
+ }
+}
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
new file mode 100644
index 00000000..61633e78
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationsAdapter.java
@@ -0,0 +1,89 @@
+package com.example.livestreaming;
+
+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.example.livestreaming.databinding.ItemConversationBinding;
+
+public class ConversationsAdapter extends ListAdapter {
+
+ public interface OnConversationClickListener {
+ void onConversationClick(ConversationItem item);
+ }
+
+ private final OnConversationClickListener onConversationClickListener;
+
+ public ConversationsAdapter(OnConversationClickListener onConversationClickListener) {
+ super(DIFF);
+ this.onConversationClickListener = onConversationClickListener;
+ }
+
+ @NonNull
+ @Override
+ public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ ItemConversationBinding binding = ItemConversationBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
+ return new VH(binding, onConversationClickListener);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull VH holder, int position) {
+ holder.bind(getItem(position));
+ }
+
+ static class VH extends RecyclerView.ViewHolder {
+
+ private final ItemConversationBinding binding;
+ private final OnConversationClickListener onConversationClickListener;
+
+ VH(ItemConversationBinding binding, OnConversationClickListener onConversationClickListener) {
+ super(binding.getRoot());
+ this.binding = binding;
+ this.onConversationClickListener = onConversationClickListener;
+ }
+
+ void bind(ConversationItem item) {
+ binding.title.setText(item != null && item.getTitle() != null ? item.getTitle() : "");
+ binding.lastMessage.setText(item != null && item.getLastMessage() != null ? item.getLastMessage() : "");
+ binding.timeText.setText(item != null && item.getTimeText() != null ? item.getTimeText() : "");
+
+ int unread = item != null ? item.getUnreadCount() : 0;
+ if (unread > 0) {
+ binding.unreadBadge.setVisibility(View.VISIBLE);
+ binding.unreadBadge.setText(unread > 99 ? "99+" : String.valueOf(unread));
+ } else {
+ binding.unreadBadge.setVisibility(View.GONE);
+ }
+
+ if (item != null && item.isMuted()) {
+ binding.muteIcon.setVisibility(View.VISIBLE);
+ } else {
+ binding.muteIcon.setVisibility(View.GONE);
+ }
+
+ binding.getRoot().setOnClickListener(v -> {
+ if (item == null) return;
+ if (onConversationClickListener != null) onConversationClickListener.onConversationClick(item);
+ });
+ }
+ }
+
+ private static final DiffUtil.ItemCallback DIFF = new DiffUtil.ItemCallback() {
+ @Override
+ public boolean areItemsTheSame(@NonNull ConversationItem oldItem, @NonNull ConversationItem newItem) {
+ String o = oldItem.getId();
+ String n = newItem.getId();
+ return o != null && o.equals(n);
+ }
+
+ @Override
+ public boolean areContentsTheSame(@NonNull ConversationItem oldItem, @NonNull ConversationItem newItem) {
+ return oldItem.equals(newItem);
+ }
+ };
+}
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
new file mode 100644
index 00000000..b27c4ea2
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/EditProfileActivity.java
@@ -0,0 +1,177 @@
+package com.example.livestreaming;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.FileProvider;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+
+import com.bumptech.glide.Glide;
+import com.example.livestreaming.databinding.ActivityEditProfileBinding;
+import com.google.android.material.bottomsheet.BottomSheetDialog;
+
+import java.io.File;
+
+public class EditProfileActivity extends AppCompatActivity {
+
+ private ActivityEditProfileBinding binding;
+
+ private static final String PREFS_NAME = "profile_prefs";
+ private static final String KEY_NAME = "profile_name";
+ private static final String KEY_BIO = "profile_bio";
+ private static final String KEY_AVATAR_URI = "profile_avatar_uri";
+ private static final String KEY_BIRTHDAY = "profile_birthday";
+ private static final String KEY_GENDER = "profile_gender";
+ private static final String KEY_LOCATION = "profile_location";
+
+ private Uri selectedAvatarUri = null;
+ private Uri pendingCameraUri = null;
+
+ private ActivityResultLauncher pickImageLauncher;
+ private ActivityResultLauncher takePictureLauncher;
+
+ public static void start(Context context) {
+ Intent intent = new Intent(context, EditProfileActivity.class);
+ context.startActivity(intent);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ binding = ActivityEditProfileBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+
+ pickImageLauncher = registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> {
+ if (uri == null) return;
+ selectedAvatarUri = uri;
+ Glide.with(this).load(uri).circleCrop().into(binding.avatarPreview);
+ });
+
+ takePictureLauncher = registerForActivityResult(new ActivityResultContracts.TakePicture(), success -> {
+ if (!success) return;
+ if (pendingCameraUri == null) return;
+ selectedAvatarUri = pendingCameraUri;
+ Glide.with(this).load(pendingCameraUri).circleCrop().into(binding.avatarPreview);
+ });
+
+ binding.backButton.setOnClickListener(v -> finish());
+ binding.cancelButton.setOnClickListener(v -> finish());
+
+ loadFromPrefs();
+
+ binding.avatarRow.setOnClickListener(v -> showAvatarBottomSheet());
+
+ binding.saveButton.setOnClickListener(v -> {
+ String name = binding.inputName.getText() != null ? binding.inputName.getText().toString().trim() : "";
+ String bio = binding.inputBio.getText() != null ? binding.inputBio.getText().toString().trim() : "";
+ String birthday = binding.inputBirthday.getText() != null ? binding.inputBirthday.getText().toString().trim() : "";
+ String gender = binding.inputGender.getText() != null ? binding.inputGender.getText().toString().trim() : "";
+ String location = binding.inputLocation.getText() != null ? binding.inputLocation.getText().toString().trim() : "";
+
+ if (TextUtils.isEmpty(name)) {
+ Toast.makeText(this, "昵称不能为空", Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
+ .edit()
+ .putString(KEY_NAME, name)
+ .apply();
+
+ if (TextUtils.isEmpty(bio)) {
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().remove(KEY_BIO).apply();
+ } else {
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putString(KEY_BIO, bio).apply();
+ }
+
+ if (selectedAvatarUri != null) {
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putString(KEY_AVATAR_URI, selectedAvatarUri.toString()).apply();
+ }
+
+ if (TextUtils.isEmpty(birthday)) {
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().remove(KEY_BIRTHDAY).apply();
+ } else {
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putString(KEY_BIRTHDAY, birthday).apply();
+ }
+
+ if (TextUtils.isEmpty(gender)) {
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().remove(KEY_GENDER).apply();
+ } else {
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putString(KEY_GENDER, gender).apply();
+ }
+
+ if (TextUtils.isEmpty(location)) {
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().remove(KEY_LOCATION).apply();
+ } else {
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putString(KEY_LOCATION, location).apply();
+ }
+
+ finish();
+ });
+ }
+
+ private void loadFromPrefs() {
+ String name = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_NAME, "");
+ String bio = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_BIO, "");
+ String birthday = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_BIRTHDAY, "");
+ String gender = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_GENDER, "");
+ String location = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_LOCATION, "");
+ String avatarUri = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_AVATAR_URI, null);
+
+ binding.inputName.setText(name);
+ binding.inputBio.setText(bio);
+ binding.inputBirthday.setText(birthday);
+ binding.inputGender.setText(gender);
+ binding.inputLocation.setText(location);
+
+ if (!TextUtils.isEmpty(avatarUri)) {
+ selectedAvatarUri = Uri.parse(avatarUri);
+ Glide.with(this).load(selectedAvatarUri).circleCrop().into(binding.avatarPreview);
+ }
+ }
+
+ private void showAvatarBottomSheet() {
+ BottomSheetDialog dialog = new BottomSheetDialog(this);
+ android.view.View view = getLayoutInflater().inflate(R.layout.bottom_sheet_avatar_picker, null);
+ dialog.setContentView(view);
+
+ android.view.View pick = view.findViewById(R.id.actionPickGallery);
+ android.view.View camera = view.findViewById(R.id.actionTakePhoto);
+ android.view.View cancel = view.findViewById(R.id.actionCancel);
+
+ pick.setOnClickListener(v -> {
+ dialog.dismiss();
+ pickImageLauncher.launch("image/*");
+ });
+
+ camera.setOnClickListener(v -> {
+ dialog.dismiss();
+ Uri uri = createTempCameraUri();
+ if (uri == null) return;
+ pendingCameraUri = uri;
+ takePictureLauncher.launch(uri);
+ });
+
+ cancel.setOnClickListener(v -> dialog.dismiss());
+
+ dialog.show();
+ }
+
+ private Uri createTempCameraUri() {
+ try {
+ File dir = new File(getCacheDir(), "images");
+ if (!dir.exists()) dir.mkdirs();
+ File file = new File(dir, "avatar_" + System.currentTimeMillis() + ".jpg");
+ 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
new file mode 100644
index 00000000..38db12e3
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/FansListActivity.java
@@ -0,0 +1,53 @@
+package com.example.livestreaming;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+import com.example.livestreaming.databinding.ActivityFansListBinding;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class FansListActivity extends AppCompatActivity {
+
+ private ActivityFansListBinding binding;
+
+ public static void start(Context context) {
+ Intent intent = new Intent(context, FansListActivity.class);
+ context.startActivity(intent);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ binding = ActivityFansListBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+
+ binding.backButton.setOnClickListener(v -> finish());
+
+ FriendsAdapter adapter = new FriendsAdapter(item -> {
+ if (item == null) return;
+ Toast.makeText(this, "打开粉丝:" + item.getName(), Toast.LENGTH_SHORT).show();
+ });
+
+ binding.fansRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+ binding.fansRecyclerView.setAdapter(adapter);
+ adapter.submitList(buildDemoFans());
+ }
+
+ private List buildDemoFans() {
+ List list = new ArrayList<>();
+ list.add(new FriendItem("f1", "小雨", "关注了你 · 2分钟前", true));
+ list.add(new FriendItem("f2", "阿宁", "关注了你 · 昨天", false));
+ list.add(new FriendItem("f3", "小星", "关注了你 · 周二", true));
+ list.add(new FriendItem("f4", "小林", "关注了你 · 上周", false));
+ list.add(new FriendItem("f5", "阿杰", "关注了你 · 上周", false));
+ list.add(new FriendItem("f6", "小七", "关注了你 · 上月", true));
+ return list;
+ }
+}
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
new file mode 100644
index 00000000..c065f270
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/FollowingListActivity.java
@@ -0,0 +1,52 @@
+package com.example.livestreaming;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+import com.example.livestreaming.databinding.ActivityFollowingListBinding;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class FollowingListActivity extends AppCompatActivity {
+
+ private ActivityFollowingListBinding binding;
+
+ public static void start(Context context) {
+ Intent intent = new Intent(context, FollowingListActivity.class);
+ context.startActivity(intent);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ binding = ActivityFollowingListBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+
+ binding.backButton.setOnClickListener(v -> finish());
+
+ FriendsAdapter adapter = new FriendsAdapter(item -> {
+ if (item == null) return;
+ Toast.makeText(this, "打开关注:" + item.getName(), Toast.LENGTH_SHORT).show();
+ });
+
+ binding.followingRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+ binding.followingRecyclerView.setAdapter(adapter);
+ adapter.submitList(buildDemoFollowing());
+ }
+
+ private List buildDemoFollowing() {
+ List list = new ArrayList<>();
+ list.add(new FriendItem("fo1", "王者荣耀陪练", "主播 · 正在直播", true));
+ list.add(new FriendItem("fo2", "音乐电台", "主播 · 今日 20:00 开播", false));
+ list.add(new FriendItem("fo3", "户外阿杰", "主播 · 1小时前开播", true));
+ list.add(new FriendItem("fo4", "美食探店", "主播 · 昨天直播", false));
+ list.add(new FriendItem("fo5", "聊天小七", "主播 · 正在直播", true));
+ return list;
+ }
+}
diff --git a/android-app/app/src/main/java/com/example/livestreaming/FriendItem.java b/android-app/app/src/main/java/com/example/livestreaming/FriendItem.java
new file mode 100644
index 00000000..4334eeb5
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/FriendItem.java
@@ -0,0 +1,50 @@
+package com.example.livestreaming;
+
+import java.util.Objects;
+
+public class FriendItem {
+
+ private final String id;
+ private final String name;
+ private final String subtitle;
+ private final boolean online;
+
+ public FriendItem(String id, String name, String subtitle, boolean online) {
+ this.id = id;
+ this.name = name;
+ this.subtitle = subtitle;
+ this.online = online;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getSubtitle() {
+ return subtitle;
+ }
+
+ public boolean isOnline() {
+ return online;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof FriendItem)) return false;
+ FriendItem that = (FriendItem) o;
+ return online == that.online
+ && Objects.equals(id, that.id)
+ && Objects.equals(name, that.name)
+ && Objects.equals(subtitle, that.subtitle);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name, subtitle, online);
+ }
+}
diff --git a/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java
new file mode 100644
index 00000000..1b4647f3
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java
@@ -0,0 +1,80 @@
+package com.example.livestreaming;
+
+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.example.livestreaming.databinding.ItemFriendBinding;
+
+public class FriendsAdapter extends ListAdapter {
+
+ public interface OnFriendClickListener {
+ void onFriendClick(FriendItem item);
+ }
+
+ private final OnFriendClickListener onFriendClickListener;
+
+ public FriendsAdapter(OnFriendClickListener onFriendClickListener) {
+ super(DIFF);
+ this.onFriendClickListener = onFriendClickListener;
+ }
+
+ @NonNull
+ @Override
+ public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ ItemFriendBinding binding = ItemFriendBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
+ return new VH(binding, onFriendClickListener);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull VH holder, int position) {
+ holder.bind(getItem(position));
+ }
+
+ static class VH extends RecyclerView.ViewHolder {
+
+ private final ItemFriendBinding binding;
+ private final OnFriendClickListener onFriendClickListener;
+
+ VH(ItemFriendBinding binding, OnFriendClickListener onFriendClickListener) {
+ super(binding.getRoot());
+ this.binding = binding;
+ this.onFriendClickListener = onFriendClickListener;
+ }
+
+ void bind(FriendItem item) {
+ binding.name.setText(item != null && item.getName() != null ? item.getName() : "");
+ binding.subtitle.setText(item != null && item.getSubtitle() != null ? item.getSubtitle() : "");
+
+ if (item != null && item.isOnline()) {
+ binding.onlineDot.setVisibility(View.VISIBLE);
+ } else {
+ binding.onlineDot.setVisibility(View.GONE);
+ }
+
+ binding.getRoot().setOnClickListener(v -> {
+ if (item == null) return;
+ if (onFriendClickListener != null) onFriendClickListener.onFriendClick(item);
+ });
+ }
+ }
+
+ private static final DiffUtil.ItemCallback DIFF = new DiffUtil.ItemCallback() {
+ @Override
+ public boolean areItemsTheSame(@NonNull FriendItem oldItem, @NonNull FriendItem newItem) {
+ String o = oldItem.getId();
+ String n = newItem.getId();
+ return o != null && o.equals(n);
+ }
+
+ @Override
+ public boolean areContentsTheSame(@NonNull FriendItem oldItem, @NonNull FriendItem newItem) {
+ return oldItem.equals(newItem);
+ }
+ };
+}
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
new file mode 100644
index 00000000..0a8df165
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/LikesListActivity.java
@@ -0,0 +1,52 @@
+package com.example.livestreaming;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+import com.example.livestreaming.databinding.ActivityLikesListBinding;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class LikesListActivity extends AppCompatActivity {
+
+ private ActivityLikesListBinding binding;
+
+ public static void start(Context context) {
+ Intent intent = new Intent(context, LikesListActivity.class);
+ context.startActivity(intent);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ binding = ActivityLikesListBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+
+ binding.backButton.setOnClickListener(v -> finish());
+
+ ConversationsAdapter adapter = new ConversationsAdapter(item -> {
+ if (item == null) return;
+ Toast.makeText(this, "查看获赞:" + item.getTitle(), Toast.LENGTH_SHORT).show();
+ });
+
+ binding.likesRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+ binding.likesRecyclerView.setAdapter(adapter);
+ adapter.submitList(buildDemoLikes());
+ }
+
+ private List buildDemoLikes() {
+ List list = new ArrayList<>();
+ list.add(new ConversationItem("l1", "小雨", "赞了你的直播间", "09:12", 0, false));
+ list.add(new ConversationItem("l2", "阿宁", "赞了你的作品", "昨天", 0, false));
+ list.add(new ConversationItem("l3", "小星", "赞了你", "周二", 0, false));
+ list.add(new ConversationItem("l4", "小林", "赞了你的直播回放", "上周", 0, false));
+ list.add(new ConversationItem("l5", "阿杰", "赞了你的作品", "上周", 0, false));
+ return list;
+ }
+}
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 a2743ee9..2695f37d 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
@@ -23,6 +23,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.example.livestreaming.databinding.ActivityMainBinding;
import com.example.livestreaming.databinding.DialogCreateRoomBinding;
import com.google.android.material.bottomnavigation.BottomNavigationView;
+import com.google.android.material.tabs.TabLayout;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.CreateRoomRequest;
@@ -42,6 +43,9 @@ public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
private RoomsAdapter adapter;
+ private final List allRooms = new ArrayList<>();
+ private String currentCategory = "推荐";
+
private final Handler handler = new Handler(Looper.getMainLooper());
private Runnable pollRunnable;
@@ -76,12 +80,56 @@ public class MainActivity extends AppCompatActivity {
binding.roomsRecyclerView.setAdapter(adapter);
// 立即显示演示数据,提升用户体验
- adapter.submitList(buildDemoRooms(6));
+ allRooms.clear();
+ allRooms.addAll(buildDemoRooms(12));
+ applyCategoryFilter(currentCategory);
}
private void setupUI() {
binding.swipeRefresh.setOnRefreshListener(this::fetchRooms);
+ binding.topTabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
+ @Override
+ public void onTabSelected(TabLayout.Tab tab) {
+ if (tab == null) return;
+ CharSequence title = tab.getText();
+ TabPlaceholderActivity.start(MainActivity.this, title != null ? title.toString() : "");
+ }
+
+ @Override
+ public void onTabUnselected(TabLayout.Tab tab) {
+ }
+
+ @Override
+ public void onTabReselected(TabLayout.Tab tab) {
+ if (tab == null) return;
+ CharSequence title = tab.getText();
+ TabPlaceholderActivity.start(MainActivity.this, title != null ? title.toString() : "");
+ }
+ });
+
+ binding.categoryTabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
+ @Override
+ public void onTabSelected(TabLayout.Tab tab) {
+ if (tab == null) return;
+ CharSequence title = tab.getText();
+ currentCategory = title != null ? title.toString() : "推荐";
+ applyCategoryFilter(currentCategory);
+ }
+
+ @Override
+ public void onTabUnselected(TabLayout.Tab tab) {
+ }
+
+ @Override
+ public void onTabReselected(TabLayout.Tab tab) {
+ if (tab == null) return;
+ CharSequence title = tab.getText();
+ currentCategory = title != null ? title.toString() : "推荐";
+ applyCategoryFilter(currentCategory);
+ }
+ });
+
binding.roomsRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
@@ -356,7 +404,9 @@ public class MainActivity extends AppCompatActivity {
if (rooms == null || rooms.isEmpty()) {
rooms = buildDemoRooms(12);
}
- adapter.submitList(rooms);
+ allRooms.clear();
+ allRooms.addAll(rooms);
+ applyCategoryFilter(currentCategory);
adapter.bumpCoverOffset();
}
@@ -365,12 +415,42 @@ public class MainActivity extends AppCompatActivity {
binding.loading.setVisibility(View.GONE);
binding.swipeRefresh.setRefreshing(false);
isFetching = false;
- adapter.submitList(buildDemoRooms(12));
+ allRooms.clear();
+ allRooms.addAll(buildDemoRooms(12));
+ applyCategoryFilter(currentCategory);
adapter.bumpCoverOffset();
}
});
}
+ private void applyCategoryFilter(String category) {
+ String c = category != null ? category : "推荐";
+ if ("推荐".equals(c)) {
+ adapter.submitList(new ArrayList<>(allRooms));
+ return;
+ }
+
+ List filtered = new ArrayList<>();
+ for (Room r : allRooms) {
+ if (r == null) continue;
+ if (c.equals(getDemoCategoryForRoom(r))) {
+ filtered.add(r);
+ }
+ }
+ adapter.submitList(filtered);
+ }
+
+ private String getDemoCategoryForRoom(Room room) {
+ String[] categories = new String[]{"游戏", "才艺", "户外", "音乐", "美食", "聊天"};
+ try {
+ String seed = room != null && room.getId() != null ? room.getId() : room != null ? room.getTitle() : "";
+ int h = Math.abs(seed != null ? seed.hashCode() : 0);
+ return categories[h % categories.length];
+ } catch (Exception ignored) {
+ return "游戏";
+ }
+ }
+
private List buildDemoRooms(int count) {
List list = new ArrayList<>();
for (int i = 0; i < count; i++) {
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 ea041a40..4afb12b5 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
@@ -3,12 +3,17 @@ package com.example.livestreaming;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
+import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.LinearLayoutManager;
import com.example.livestreaming.databinding.ActivityMessagesBinding;
import com.google.android.material.bottomnavigation.BottomNavigationView;
+import java.util.ArrayList;
+import java.util.List;
+
public class MessagesActivity extends AppCompatActivity {
private ActivityMessagesBinding binding;
@@ -24,6 +29,8 @@ public class MessagesActivity extends AppCompatActivity {
binding = ActivityMessagesBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
+ setupConversationList();
+
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_messages);
bottomNavigation.setOnItemSelectedListener(item -> {
@@ -55,6 +62,28 @@ public class MessagesActivity extends AppCompatActivity {
});
}
+ private void setupConversationList() {
+ ConversationsAdapter adapter = new ConversationsAdapter(item -> {
+ if (item == null) return;
+ Toast.makeText(this, "打开会话:" + item.getTitle(), Toast.LENGTH_SHORT).show();
+ });
+ binding.conversationsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+ binding.conversationsRecyclerView.setAdapter(adapter);
+ adapter.submitList(buildDemoConversations());
+ }
+
+ private List buildDemoConversations() {
+ List list = new ArrayList<>();
+ list.add(new ConversationItem("sys", "系统通知", "欢迎来到直播间~新手指南已送达", "09:12", 2, false));
+ list.add(new ConversationItem("a", "小王(主播)", "今晚8点开播,记得来捧场!", "昨天", 0, false));
+ list.add(new ConversationItem("b", "附近的人", "嗨~一起连麦吗?", "昨天", 5, false));
+ list.add(new ConversationItem("c", "运营小助手", "活动报名已通过,点击查看详情", "周二", 0, true));
+ list.add(new ConversationItem("d", "直播间群聊", "[图片]", "周一", 19, false));
+ list.add(new ConversationItem("e", "小李", "收到啦", "周一", 0, false));
+ list.add(new ConversationItem("f", "客服", "您好,请描述一下遇到的问题", "上周", 1, false));
+ return list;
+ }
+
@Override
protected void onResume() {
super.onResume();
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
new file mode 100644
index 00000000..03aeddc5
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java
@@ -0,0 +1,89 @@
+package com.example.livestreaming;
+
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+import com.example.livestreaming.databinding.ActivityMyFriendsBinding;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class MyFriendsActivity extends AppCompatActivity {
+
+ private ActivityMyFriendsBinding binding;
+ private FriendsAdapter adapter;
+ private final List all = new ArrayList<>();
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ binding = ActivityMyFriendsBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+
+ binding.backButton.setOnClickListener(v -> finish());
+
+ adapter = new FriendsAdapter(item -> {
+ if (item == null) return;
+ Toast.makeText(this, "打开挚友:" + item.getName(), Toast.LENGTH_SHORT).show();
+ });
+
+ binding.friendsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+ binding.friendsRecyclerView.setAdapter(adapter);
+
+ all.clear();
+ all.addAll(buildDemoFriends());
+ adapter.submitList(new ArrayList<>(all));
+
+ binding.searchEdit.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) {
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ String q = s != null ? s.toString().trim() : "";
+ applyFilter(q);
+ }
+ });
+ }
+
+ private void applyFilter(String query) {
+ if (query == null || query.trim().isEmpty()) {
+ adapter.submitList(new ArrayList<>(all));
+ return;
+ }
+ String q = query.toLowerCase();
+ List filtered = new ArrayList<>();
+ for (FriendItem f : all) {
+ if (f == null) continue;
+ String name = f.getName() != null ? f.getName() : "";
+ String sub = f.getSubtitle() != null ? f.getSubtitle() : "";
+ if (name.toLowerCase().contains(q) || sub.toLowerCase().contains(q)) {
+ filtered.add(f);
+ }
+ }
+ adapter.submitList(filtered);
+ }
+
+ private List buildDemoFriends() {
+ List list = new ArrayList<>();
+ list.add(new FriendItem("1", "小王", "最近在线:5分钟前", true));
+ list.add(new FriendItem("2", "小李", "共同关注:王者荣耀", false));
+ list.add(new FriendItem("3", "安安", "备注:一起连麦", true));
+ list.add(new FriendItem("4", "小陈", "最近在线:昨天", false));
+ list.add(new FriendItem("5", "小美", "共同关注:音乐", true));
+ list.add(new FriendItem("6", "老张", "最近在线:3天前", false));
+ list.add(new FriendItem("7", "小七", "备注:挚友", true));
+ list.add(new FriendItem("8", "阿杰", "共同关注:美食", false));
+ return list;
+ }
+}
diff --git a/android-app/app/src/main/java/com/example/livestreaming/NearbyUser.java b/android-app/app/src/main/java/com/example/livestreaming/NearbyUser.java
new file mode 100644
index 00000000..216d9b45
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/NearbyUser.java
@@ -0,0 +1,50 @@
+package com.example.livestreaming;
+
+import java.util.Objects;
+
+public class NearbyUser {
+
+ private final String id;
+ private final String name;
+ private final String distanceText;
+ private final boolean isLive;
+
+ public NearbyUser(String id, String name, String distanceText, boolean isLive) {
+ this.id = id;
+ this.name = name;
+ this.distanceText = distanceText;
+ this.isLive = isLive;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getDistanceText() {
+ return distanceText;
+ }
+
+ public boolean isLive() {
+ return isLive;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof NearbyUser)) return false;
+ NearbyUser that = (NearbyUser) o;
+ return isLive == that.isLive
+ && Objects.equals(id, that.id)
+ && Objects.equals(name, that.name)
+ && Objects.equals(distanceText, that.distanceText);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name, distanceText, isLive);
+ }
+}
diff --git a/android-app/app/src/main/java/com/example/livestreaming/NearbyUsersAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/NearbyUsersAdapter.java
new file mode 100644
index 00000000..8d77549d
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/NearbyUsersAdapter.java
@@ -0,0 +1,104 @@
+package com.example.livestreaming;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.ListAdapter;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.example.livestreaming.databinding.ItemNearbyUserBinding;
+
+public class NearbyUsersAdapter extends ListAdapter {
+
+ public interface OnUserClickListener {
+ void onUserClick(NearbyUser user);
+ }
+
+ private final OnUserClickListener onUserClickListener;
+
+ public NearbyUsersAdapter(OnUserClickListener onUserClickListener) {
+ super(DIFF);
+ this.onUserClickListener = onUserClickListener;
+ }
+
+ @NonNull
+ @Override
+ public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ ItemNearbyUserBinding binding = ItemNearbyUserBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
+ return new VH(binding, onUserClickListener);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull VH holder, int position) {
+ holder.bind(getItem(position));
+ }
+
+ static class VH extends RecyclerView.ViewHolder {
+
+ private final ItemNearbyUserBinding binding;
+ private final OnUserClickListener onUserClickListener;
+
+ VH(ItemNearbyUserBinding binding, OnUserClickListener onUserClickListener) {
+ super(binding.getRoot());
+ this.binding = binding;
+ this.onUserClickListener = onUserClickListener;
+ }
+
+ void bind(NearbyUser user) {
+ binding.userName.setText(user != null && user.getName() != null ? user.getName() : "");
+ binding.distanceText.setText(user != null && user.getDistanceText() != null ? user.getDistanceText() : "");
+
+ try {
+ String seed = user != null && user.getId() != null ? user.getId() : String.valueOf(getBindingAdapterPosition());
+ int h = Math.abs(seed.hashCode());
+ int mode = h % 3;
+ String ratio;
+ if (mode == 0) {
+ ratio = "1:1";
+ } else if (mode == 1) {
+ ratio = "3:4";
+ } else {
+ ratio = "4:3";
+ }
+ ViewGroup.LayoutParams lp = binding.avatarImage.getLayoutParams();
+ if (lp instanceof ConstraintLayout.LayoutParams) {
+ ConstraintLayout.LayoutParams clp = (ConstraintLayout.LayoutParams) lp;
+ if (clp.dimensionRatio == null || !clp.dimensionRatio.equals(ratio)) {
+ clp.dimensionRatio = ratio;
+ binding.avatarImage.setLayoutParams(clp);
+ }
+ }
+ } catch (Exception ignored) {
+ }
+
+ if (user != null && user.isLive()) {
+ binding.liveBadge.setVisibility(View.VISIBLE);
+ } else {
+ binding.liveBadge.setVisibility(View.GONE);
+ }
+
+ binding.getRoot().setOnClickListener(v -> {
+ if (user == null) return;
+ if (onUserClickListener != null) onUserClickListener.onUserClick(user);
+ });
+ }
+ }
+
+ private static final DiffUtil.ItemCallback DIFF = new DiffUtil.ItemCallback() {
+ @Override
+ public boolean areItemsTheSame(@NonNull NearbyUser oldItem, @NonNull NearbyUser newItem) {
+ String o = oldItem.getId();
+ String n = newItem.getId();
+ return o != null && o.equals(n);
+ }
+
+ @Override
+ public boolean areContentsTheSame(@NonNull NearbyUser oldItem, @NonNull NearbyUser newItem) {
+ return oldItem.equals(newItem);
+ }
+ };
+}
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 89e53492..dbabf52b 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
@@ -3,9 +3,14 @@ package com.example.livestreaming;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
+import android.text.TextUtils;
+import android.widget.EditText;
+import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AlertDialog;
+import com.bumptech.glide.Glide;
import com.example.livestreaming.databinding.ActivityProfileBinding;
import com.google.android.material.bottomnavigation.BottomNavigationView;
@@ -13,6 +18,18 @@ public class ProfileActivity extends AppCompatActivity {
private ActivityProfileBinding binding;
+ private static final String PREFS_NAME = "profile_prefs";
+ private static final String KEY_NAME = "profile_name";
+ private static final String KEY_BIO = "profile_bio";
+ private static final String KEY_LEVEL = "profile_level";
+ private static final String KEY_FANS_BADGE = "profile_fans_badge";
+ private static final String KEY_BADGE = "profile_badge";
+ private static final String KEY_AVATAR_RES = "profile_avatar_res";
+ private static final String KEY_AVATAR_URI = "profile_avatar_uri";
+ private static final String KEY_BIRTHDAY = "profile_birthday";
+ private static final String KEY_GENDER = "profile_gender";
+ private static final String KEY_LOCATION = "profile_location";
+
public static void start(Context context) {
Intent intent = new Intent(context, ProfileActivity.class);
context.startActivity(intent);
@@ -24,6 +41,10 @@ public class ProfileActivity extends AppCompatActivity {
binding = ActivityProfileBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
+ loadProfileFromPrefs();
+ setupEditableAreas();
+ setupNavigationClicks();
+
BottomNavigationView bottomNavigation = binding.bottomNavInclude.bottomNavigation;
bottomNavigation.setSelectedItemId(R.id.nav_profile);
bottomNavigation.setOnItemSelectedListener(item -> {
@@ -52,10 +73,96 @@ public class ProfileActivity extends AppCompatActivity {
});
}
+ private void loadProfileFromPrefs() {
+ String n = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_NAME, null);
+ if (!TextUtils.isEmpty(n)) binding.name.setText(n);
+
+ String bio = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_BIO, null);
+ if (!TextUtils.isEmpty(bio)) {
+ binding.bioText.setText(bio);
+ binding.bioText.setTextColor(0xFF111111);
+ }
+
+ String avatarUri = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_AVATAR_URI, null);
+ if (!TextUtils.isEmpty(avatarUri)) {
+ Glide.with(this).load(avatarUri).into(binding.avatar);
+ } else {
+ int avatarRes = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getInt(KEY_AVATAR_RES, 0);
+ if (avatarRes != 0) {
+ binding.avatar.setImageResource(avatarRes);
+ }
+ }
+
+ // 等级/称号/徽章:保持固定显示(例如“月亮/星耀/至尊”),不从本地缓存覆盖。
+ }
+
+ private void setupEditableAreas() {
+ 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();
+ }));
+
+ binding.bioText.setOnClickListener(v -> showEditDialog("编辑签名", binding.bioText.getText() != null ? binding.bioText.getText().toString() : "", text -> {
+ if (TextUtils.isEmpty(text)) {
+ binding.bioText.setText("填写个人签名更容易获得关注,点击此处添加");
+ binding.bioText.setTextColor(0xFF999999);
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().remove(KEY_BIO).apply();
+ } else {
+ binding.bioText.setText(text);
+ binding.bioText.setTextColor(0xFF111111);
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putString(KEY_BIO, text).apply();
+ }
+ }));
+
+ }
+
+ private void setupNavigationClicks() {
+ binding.topActionSearch.setOnClickListener(v -> TabPlaceholderActivity.start(this, "搜索"));
+ binding.topActionClock.setOnClickListener(v -> TabPlaceholderActivity.start(this, "定位/发现"));
+ binding.topActionMore.setOnClickListener(v -> TabPlaceholderActivity.start(this, "更多"));
+
+ binding.following.setOnClickListener(v -> FollowingListActivity.start(this));
+ binding.followers.setOnClickListener(v -> FansListActivity.start(this));
+ binding.likes.setOnClickListener(v -> LikesListActivity.start(this));
+
+ binding.recordBtn.setOnClickListener(v -> Toast.makeText(this, "录制声音(待实现)", Toast.LENGTH_SHORT).show());
+
+ binding.action1.setOnClickListener(v -> TabPlaceholderActivity.start(this, "公园勋章"));
+ binding.action2.setOnClickListener(v -> WatchHistoryActivity.start(this));
+ binding.action3.setOnClickListener(v -> startActivity(new Intent(this, MyFriendsActivity.class)));
+
+ binding.editProfile.setOnClickListener(v -> EditProfileActivity.start(this));
+ binding.shareHome.setOnClickListener(v -> TabPlaceholderActivity.start(this, "分享主页"));
+ binding.addFriendBtn.setOnClickListener(v -> TabPlaceholderActivity.start(this, "加好友"));
+ }
+
+ private interface OnTextSaved {
+ void onSaved(String text);
+ }
+
+ private void showEditDialog(String title, String initialValue, OnTextSaved onSaved) {
+ EditText editText = new EditText(this);
+ editText.setText(initialValue != null ? initialValue : "");
+ editText.setSelection(editText.getText() != null ? editText.getText().length() : 0);
+ int pad = (int) (16 * getResources().getDisplayMetrics().density);
+ editText.setPadding(pad, pad, pad, pad);
+
+ new AlertDialog.Builder(this)
+ .setTitle(title)
+ .setView(editText)
+ .setNegativeButton("取消", null)
+ .setPositiveButton("保存", (d, w) -> {
+ String t = editText.getText() != null ? editText.getText().toString().trim() : "";
+ if (onSaved != null) onSaved.onSaved(t);
+ })
+ .show();
+ }
+
@Override
protected void onResume() {
super.onResume();
if (binding != null) {
+ loadProfileFromPrefs();
binding.bottomNavInclude.bottomNavigation.setSelectedItemId(R.id.nav_profile);
}
}
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
new file mode 100644
index 00000000..69d971c9
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/TabPlaceholderActivity.java
@@ -0,0 +1,127 @@
+package com.example.livestreaming;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.StaggeredGridLayoutManager;
+
+import com.example.livestreaming.databinding.ActivityTabPlaceholderBinding;
+import com.example.livestreaming.net.Room;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TabPlaceholderActivity extends AppCompatActivity {
+
+ public static final String EXTRA_TITLE = "extra_title";
+
+ private ActivityTabPlaceholderBinding binding;
+
+ public static void start(Context context, String title) {
+ Intent intent = new Intent(context, TabPlaceholderActivity.class);
+ intent.putExtra(EXTRA_TITLE, title);
+ context.startActivity(intent);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ binding = ActivityTabPlaceholderBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+
+ String title = getIntent() != null ? getIntent().getStringExtra(EXTRA_TITLE) : null;
+ if (title == null) title = "";
+ binding.titleText.setText(title);
+
+ binding.backButton.setOnClickListener(v -> finish());
+
+ renderByTitle(title);
+ }
+
+ private void renderByTitle(String title) {
+ if ("关注".equals(title)) {
+ showFollowRooms();
+ return;
+ }
+ if ("附近".equals(title)) {
+ showNearbyUsers();
+ return;
+ }
+
+ binding.genericScroll.setVisibility(View.VISIBLE);
+ binding.followRecyclerView.setVisibility(View.GONE);
+ binding.nearbyRecyclerView.setVisibility(View.GONE);
+ }
+
+ private void showFollowRooms() {
+ binding.genericScroll.setVisibility(View.GONE);
+ binding.followRecyclerView.setVisibility(View.VISIBLE);
+ binding.nearbyRecyclerView.setVisibility(View.GONE);
+
+ RoomsAdapter adapter = new RoomsAdapter(room -> {
+ if (room == null) return;
+ Intent intent = new Intent(TabPlaceholderActivity.this, RoomDetailActivity.class);
+ intent.putExtra(RoomDetailActivity.EXTRA_ROOM_ID, room.getId());
+ startActivity(intent);
+ });
+
+ StaggeredGridLayoutManager glm = new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL);
+ glm.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS);
+ binding.followRecyclerView.setLayoutManager(glm);
+ binding.followRecyclerView.setAdapter(adapter);
+
+ adapter.submitList(buildFollowDemoRooms(16));
+ }
+
+ private void showNearbyUsers() {
+ binding.genericScroll.setVisibility(View.GONE);
+ binding.followRecyclerView.setVisibility(View.GONE);
+ binding.nearbyRecyclerView.setVisibility(View.VISIBLE);
+
+ NearbyUsersAdapter adapter = new NearbyUsersAdapter(user -> {
+ if (user == null) return;
+ Toast.makeText(this, "点击:" + user.getName(), Toast.LENGTH_SHORT).show();
+ });
+
+ StaggeredGridLayoutManager glm = new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL);
+ glm.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS);
+ binding.nearbyRecyclerView.setLayoutManager(glm);
+ binding.nearbyRecyclerView.setAdapter(adapter);
+
+ adapter.submitList(buildNearbyDemoUsers(18));
+ }
+
+ private List buildFollowDemoRooms(int count) {
+ List list = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ String id = "follow-" + i;
+ String title = "关注主播直播间 " + (i + 1);
+ String streamer = "已关注主播" + (i + 1);
+ boolean live = i % 4 != 0;
+ list.add(new Room(id, title, streamer, live));
+ }
+ return list;
+ }
+
+ private List buildNearbyDemoUsers(int count) {
+ List list = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ String id = "nearby-" + i;
+ String name = "附近主播" + (i + 1);
+ boolean live = i % 3 == 0;
+ String distanceText;
+ if (i < 3) {
+ distanceText = (300 + i * 120) + "m";
+ } else {
+ float km = 0.8f + (i - 3) * 0.35f;
+ distanceText = String.format("%.1fkm", km);
+ }
+ list.add(new NearbyUser(id, name, distanceText, live));
+ }
+ return list;
+ }
+}
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
new file mode 100644
index 00000000..4b7b4f32
--- /dev/null
+++ b/android-app/app/src/main/java/com/example/livestreaming/WatchHistoryActivity.java
@@ -0,0 +1,59 @@
+package com.example.livestreaming;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.StaggeredGridLayoutManager;
+
+import com.example.livestreaming.databinding.ActivityWatchHistoryBinding;
+import com.example.livestreaming.net.Room;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class WatchHistoryActivity extends AppCompatActivity {
+
+ private ActivityWatchHistoryBinding binding;
+
+ public static void start(Context context) {
+ Intent intent = new Intent(context, WatchHistoryActivity.class);
+ context.startActivity(intent);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ binding = ActivityWatchHistoryBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+
+ binding.backButton.setOnClickListener(v -> finish());
+
+ RoomsAdapter adapter = new RoomsAdapter(room -> {
+ if (room == null) return;
+ Intent intent = new Intent(WatchHistoryActivity.this, RoomDetailActivity.class);
+ intent.putExtra(RoomDetailActivity.EXTRA_ROOM_ID, room.getId());
+ startActivity(intent);
+ });
+
+ StaggeredGridLayoutManager glm = new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL);
+ glm.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS);
+ binding.roomsRecyclerView.setLayoutManager(glm);
+ binding.roomsRecyclerView.setAdapter(adapter);
+
+ adapter.submitList(buildDemoHistory(18));
+ }
+
+ private List buildDemoHistory(int count) {
+ List list = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ String id = "history-" + i;
+ String title = "看过的直播间 " + (i + 1);
+ String streamer = "主播" + (i + 1);
+ boolean live = i % 5 != 0;
+ list.add(new Room(id, title, streamer, live));
+ }
+ return list;
+ }
+}
diff --git a/android-app/app/src/main/res/drawable/bg_circle_outline_transparent.xml b/android-app/app/src/main/res/drawable/bg_circle_outline_transparent.xml
new file mode 100644
index 00000000..1e87cd41
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/bg_circle_outline_transparent.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/drawable/bg_purple_999.xml b/android-app/app/src/main/res/drawable/bg_purple_999.xml
new file mode 100644
index 00000000..64ca7061
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/bg_purple_999.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android-app/app/src/main/res/drawable/bg_wish_tree_banner.xml b/android-app/app/src/main/res/drawable/bg_wish_tree_banner.xml
new file mode 100644
index 00000000..f8455bb6
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/bg_wish_tree_banner.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android-app/app/src/main/res/drawable/bg_wish_tree_timer.xml b/android-app/app/src/main/res/drawable/bg_wish_tree_timer.xml
new file mode 100644
index 00000000..a463f3f5
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/bg_wish_tree_timer.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android-app/app/src/main/res/drawable/ic_arrow_back_24.xml b/android-app/app/src/main/res/drawable/ic_arrow_back_24.xml
new file mode 100644
index 00000000..c76877da
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_arrow_back_24.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/drawable/ic_globe_clean.png b/android-app/app/src/main/res/drawable/ic_globe_clean.png
new file mode 100644
index 00000000..d9bd2a39
Binary files /dev/null and b/android-app/app/src/main/res/drawable/ic_globe_clean.png differ
diff --git a/android-app/app/src/main/res/drawable/ic_globe_clean_filled.png b/android-app/app/src/main/res/drawable/ic_globe_clean_filled.png
new file mode 100644
index 00000000..c8faf280
Binary files /dev/null and b/android-app/app/src/main/res/drawable/ic_globe_clean_filled.png differ
diff --git a/android-app/app/src/main/res/drawable/ic_globe_no_bg.png b/android-app/app/src/main/res/drawable/ic_globe_no_bg.png
new file mode 100644
index 00000000..2aea2221
Binary files /dev/null and b/android-app/app/src/main/res/drawable/ic_globe_no_bg.png differ
diff --git a/android-app/app/src/main/res/drawable/wish_tree.png b/android-app/app/src/main/res/drawable/wish_tree.png
new file mode 100644
index 00000000..7d1da786
Binary files /dev/null and b/android-app/app/src/main/res/drawable/wish_tree.png differ
diff --git a/android-app/app/src/main/res/drawable/wish_tree_black.jpg b/android-app/app/src/main/res/drawable/wish_tree_black.jpg
new file mode 100644
index 00000000..216c1cbd
Binary files /dev/null and b/android-app/app/src/main/res/drawable/wish_tree_black.jpg differ
diff --git a/android-app/app/src/main/res/drawable/wish_tree_checker_backup.png b/android-app/app/src/main/res/drawable/wish_tree_checker_backup.png
new file mode 100644
index 00000000..9db62438
Binary files /dev/null and b/android-app/app/src/main/res/drawable/wish_tree_checker_backup.png differ
diff --git a/android-app/app/src/main/res/drawable/wish_tree_prev_no_bg.png b/android-app/app/src/main/res/drawable/wish_tree_prev_no_bg.png
new file mode 100644
index 00000000..9eb1c88b
Binary files /dev/null and b/android-app/app/src/main/res/drawable/wish_tree_prev_no_bg.png differ
diff --git a/android-app/app/src/main/res/drawable/wish_tree_trim_backup.png b/android-app/app/src/main/res/drawable/wish_tree_trim_backup.png
new file mode 100644
index 00000000..ad0aed14
Binary files /dev/null and b/android-app/app/src/main/res/drawable/wish_tree_trim_backup.png differ
diff --git a/android-app/app/src/main/res/layout/activity_edit_profile.xml b/android-app/app/src/main/res/layout/activity_edit_profile.xml
new file mode 100644
index 00000000..1ffae33a
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_edit_profile.xml
@@ -0,0 +1,273 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/activity_fans_list.xml b/android-app/app/src/main/res/layout/activity_fans_list.xml
new file mode 100644
index 00000000..fc90ab6a
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_fans_list.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/activity_fish_pond.xml b/android-app/app/src/main/res/layout/activity_fish_pond.xml
index df46cf12..5613a4fd 100644
--- a/android-app/app/src/main/res/layout/activity_fish_pond.xml
+++ b/android-app/app/src/main/res/layout/activity_fish_pond.xml
@@ -97,13 +97,15 @@
+ android:padding="0dp"
+ android:scaleType="fitCenter"
+ android:src="@drawable/ic_globe_clean_filled" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/activity_likes_list.xml b/android-app/app/src/main/res/layout/activity_likes_list.xml
new file mode 100644
index 00000000..7cf4c6a0
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_likes_list.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 7767de06..ca46d7f1 100644
--- a/android-app/app/src/main/res/layout/activity_messages.xml
+++ b/android-app/app/src/main/res/layout/activity_messages.xml
@@ -5,16 +5,44 @@
android:layout_height="match_parent"
android:background="@android:color/white">
-
+
+
+
+
+
+
+
+
+
+
+ app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 00000000..49290ac8
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_room_detail_new.xml
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/activity_tab_placeholder.xml b/android-app/app/src/main/res/layout/activity_tab_placeholder.xml
new file mode 100644
index 00000000..55cc6c39
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_tab_placeholder.xml
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/activity_watch_history.xml b/android-app/app/src/main/res/layout/activity_watch_history.xml
new file mode 100644
index 00000000..47deeaa2
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_watch_history.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/activity_wish_tree.xml b/android-app/app/src/main/res/layout/activity_wish_tree.xml
index 6e091d49..26b095fa 100644
--- a/android-app/app/src/main/res/layout/activity_wish_tree.xml
+++ b/android-app/app/src/main/res/layout/activity_wish_tree.xml
@@ -5,16 +5,124 @@
android:layout_height="match_parent"
android:background="@android:color/white">
-
+ android:background="#FFF7F7"
+ android:paddingBottom="88dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/dialog_stream_info.xml b/android-app/app/src/main/res/layout/dialog_stream_info.xml
new file mode 100644
index 00000000..cdd2386b
--- /dev/null
+++ b/android-app/app/src/main/res/layout/dialog_stream_info.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/item_chat_message.xml b/android-app/app/src/main/res/layout/item_chat_message.xml
new file mode 100644
index 00000000..2f072f83
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_chat_message.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/android-app/app/src/main/res/layout/item_conversation.xml b/android-app/app/src/main/res/layout/item_conversation.xml
new file mode 100644
index 00000000..623c30a0
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_conversation.xml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/item_friend.xml b/android-app/app/src/main/res/layout/item_friend.xml
new file mode 100644
index 00000000..3eb57b45
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_friend.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/item_nearby_user.xml b/android-app/app/src/main/res/layout/item_nearby_user.xml
new file mode 100644
index 00000000..1723e6a5
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_nearby_user.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/xml/file_paths.xml b/android-app/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 00000000..df12191a
--- /dev/null
+++ b/android-app/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android-app/img/无背景地球图标.png b/android-app/img/无背景地球图标.png
new file mode 100644
index 00000000..2aea2221
Binary files /dev/null and b/android-app/img/无背景地球图标.png differ
diff --git a/android-app/img/有背景图的许愿树.png b/android-app/img/有背景图的许愿树.png
new file mode 100644
index 00000000..7d1da786
Binary files /dev/null and b/android-app/img/有背景图的许愿树.png differ
diff --git a/android-app/img/许愿树.png b/android-app/img/许愿树.png
new file mode 100644
index 00000000..9db62438
Binary files /dev/null and b/android-app/img/许愿树.png differ
diff --git a/android-app/scripts/remove_checkerboard.ps1 b/android-app/scripts/remove_checkerboard.ps1
new file mode 100644
index 00000000..83ae8e80
--- /dev/null
+++ b/android-app/scripts/remove_checkerboard.ps1
@@ -0,0 +1,62 @@
+param(
+ [Parameter(Mandatory=$true)][string]$InputPath,
+ [Parameter(Mandatory=$true)][string]$OutputPath,
+ [string]$BackupPath = ""
+)
+
+if (!(Test-Path -LiteralPath $InputPath)) {
+ throw "Input file not found: $InputPath"
+}
+
+if ($BackupPath -ne "") {
+ Copy-Item -LiteralPath $InputPath -Destination $BackupPath -Force
+}
+
+Add-Type -AssemblyName System.Drawing
+
+# Load from a stream to avoid locking the input file (required if output overwrites input)
+$fs = [System.IO.File]::Open($InputPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
+try {
+ $bmp = New-Object System.Drawing.Bitmap($fs)
+} finally {
+ $fs.Dispose()
+}
+
+$out = New-Object System.Drawing.Bitmap($bmp.Width, $bmp.Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
+
+function IsBgPixel($c) {
+ $r = [int]$c.R
+ $g = [int]$c.G
+ $b = [int]$c.B
+
+ $nearGray = ([math]::Abs($r - $g) -le 10) -and ([math]::Abs($g - $b) -le 10)
+ if (-not $nearGray) { return $false }
+
+ # Typical checkerboard: very light gray and mid light gray
+ $isLight = ($r -ge 235 -and $g -ge 235 -and $b -ge 235)
+ $isMid = ($r -ge 205 -and $g -ge 205 -and $b -ge 205 -and $r -le 235)
+ return ($isLight -or $isMid)
+}
+
+for ($y = 0; $y -lt $bmp.Height; $y++) {
+ for ($x = 0; $x -lt $bmp.Width; $x++) {
+ $c = $bmp.GetPixel($x, $y)
+
+ if (IsBgPixel $c) {
+ $out.SetPixel($x, $y, [System.Drawing.Color]::FromArgb(0, $c.R, $c.G, $c.B))
+ } else {
+ $out.SetPixel($x, $y, [System.Drawing.Color]::FromArgb(255, $c.R, $c.G, $c.B))
+ }
+ }
+}
+
+$tmp = "$OutputPath.tmp"
+
+try {
+ $out.Save($tmp, [System.Drawing.Imaging.ImageFormat]::Png)
+} finally {
+ $bmp.Dispose()
+ $out.Dispose()
+}
+
+Move-Item -LiteralPath $tmp -Destination $OutputPath -Force
diff --git a/android-app/scripts/trim_transparent.ps1 b/android-app/scripts/trim_transparent.ps1
new file mode 100644
index 00000000..dd157211
--- /dev/null
+++ b/android-app/scripts/trim_transparent.ps1
@@ -0,0 +1,70 @@
+param(
+ [Parameter(Mandatory=$true)][string]$InputPath,
+ [Parameter(Mandatory=$true)][string]$OutputPath,
+ [string]$BackupPath = "",
+ [int]$AlphaThreshold = 1,
+ [int]$Padding = 0
+)
+
+if (!(Test-Path -LiteralPath $InputPath)) {
+ throw "Input file not found: $InputPath"
+}
+
+if ($BackupPath -ne "") {
+ Copy-Item -LiteralPath $InputPath -Destination $BackupPath -Force
+}
+
+Add-Type -AssemblyName System.Drawing
+
+# Load from stream to avoid locking
+$fs = [System.IO.File]::Open($InputPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
+try {
+ $bmp = New-Object System.Drawing.Bitmap($fs)
+} finally {
+ $fs.Dispose()
+}
+
+$minX = $bmp.Width
+$minY = $bmp.Height
+$maxX = -1
+$maxY = -1
+
+for ($y = 0; $y -lt $bmp.Height; $y++) {
+ for ($x = 0; $x -lt $bmp.Width; $x++) {
+ $c = $bmp.GetPixel($x, $y)
+ if ($c.A -ge $AlphaThreshold) {
+ if ($x -lt $minX) { $minX = $x }
+ if ($y -lt $minY) { $minY = $y }
+ if ($x -gt $maxX) { $maxX = $x }
+ if ($y -gt $maxY) { $maxY = $y }
+ }
+ }
+}
+
+if ($maxX -lt 0 -or $maxY -lt 0) {
+ # No non-transparent pixels, just copy input
+ $bmp.Dispose()
+ Copy-Item -LiteralPath $InputPath -Destination $OutputPath -Force
+ exit 0
+}
+
+$minX = [math]::Max(0, $minX - $Padding)
+$minY = [math]::Max(0, $minY - $Padding)
+$maxX = [math]::Min($bmp.Width - 1, $maxX + $Padding)
+$maxY = [math]::Min($bmp.Height - 1, $maxY + $Padding)
+
+$newW = $maxX - $minX + 1
+$newH = $maxY - $minY + 1
+
+$rect = New-Object System.Drawing.Rectangle($minX, $minY, $newW, $newH)
+$cropped = $bmp.Clone($rect, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
+
+$tmp = "$OutputPath.tmp"
+try {
+ $cropped.Save($tmp, [System.Drawing.Imaging.ImageFormat]::Png)
+} finally {
+ $cropped.Dispose()
+ $bmp.Dispose()
+}
+
+Move-Item -LiteralPath $tmp -Destination $OutputPath -Force