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