feat: add conversation selection mode and swipe tabs

This commit is contained in:
kris
2026-04-04 00:32:57 +08:00
parent c30b0a66ae
commit 5157a0ac07
9 changed files with 468 additions and 25 deletions

View File

@@ -565,6 +565,16 @@ public final class BossUi {
Context context,
WechatSurfaceMapper.ConversationRow row,
@Nullable View.OnClickListener listener
) {
return buildConversationRow(context, row, false, false, listener);
}
public static LinearLayout buildConversationRow(
Context context,
WechatSurfaceMapper.ConversationRow row,
boolean selectionMode,
boolean selected,
@Nullable View.OnClickListener listener
) {
LinearLayout card = new LinearLayout(context);
card.setOrientation(LinearLayout.HORIZONTAL);
@@ -583,6 +593,9 @@ public final class BossUi {
card.setFocusable(true);
card.setOnClickListener(listener);
}
if (selectionMode) {
card.setContentDescription(selected ? "已选中会话" : "未选中会话");
}
card.addView(buildConversationAvatar(context, row));
@@ -662,6 +675,14 @@ public final class BossUi {
));
trailingColumn.addView(timeView);
if (selectionMode) {
View selector = buildConversationSelectionIndicator(context, selected);
LinearLayout.LayoutParams selectorParams = new LinearLayout.LayoutParams(dp(context, 20), dp(context, 20));
selectorParams.topMargin = dp(context, 8);
selector.setLayoutParams(selectorParams);
trailingColumn.addView(selector);
}
if (row.unreadCount > 0) {
TextView unreadView = new TextView(context);
unreadView.setText(row.unreadCount > 99 ? "99+" : String.valueOf(row.unreadCount));
@@ -730,6 +751,17 @@ public final class BossUi {
return card;
}
private static View buildConversationSelectionIndicator(Context context, boolean selected) {
View indicator = new View(context);
GradientDrawable drawable = new GradientDrawable();
drawable.setShape(GradientDrawable.OVAL);
drawable.setColor(selected ? context.getColor(R.color.boss_green) : Color.WHITE);
drawable.setStroke(dp(context, 1.5f), selected ? context.getColor(R.color.boss_green) : Color.parseColor("#FFC8C8C8"));
indicator.setBackground(drawable);
indicator.setContentDescription(selected ? "已选中会话" : "未选中会话");
return indicator;
}
public static LinearLayout buildConversationSectionHeader(
Context context,
String title,

View File

@@ -6,6 +6,8 @@ import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
@@ -22,6 +24,10 @@ import org.json.JSONArray;
import org.json.JSONObject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -69,8 +75,11 @@ public class MainActivity extends AppCompatActivity {
private @Nullable String boundDeviceName;
private String conversationSearchQuery = "";
private boolean pinnedConversationsCollapsed = false;
private boolean conversationSelectionMode = false;
private boolean conversationAutoRefreshArmed = false;
private boolean conversationAutoRefreshEnabled = false;
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
private @Nullable GestureDetector conversationPagerGestureDetector;
private final Runnable conversationAutoRefreshRunnable = new Runnable() {
@Override
public void run() {
@@ -186,6 +195,38 @@ public class MainActivity extends AppCompatActivity {
tabDevices.setOnClickListener(v -> setActiveTab("devices", true));
tabMe.setOnClickListener(v -> setActiveTab("me", true));
screenRefresh.setOnRefreshListener(this::refreshCurrentTab);
conversationPagerGestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() {
private static final int SWIPE_DISTANCE_THRESHOLD = 120;
private static final int SWIPE_VELOCITY_THRESHOLD = 120;
@Override
public boolean onDown(MotionEvent e) {
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (e1 == null || e2 == null) {
return false;
}
float deltaX = e2.getX() - e1.getX();
float deltaY = e2.getY() - e1.getY();
if (Math.abs(deltaX) < Math.abs(deltaY)) {
return false;
}
if (Math.abs(deltaX) < SWIPE_DISTANCE_THRESHOLD || Math.abs(velocityX) < SWIPE_VELOCITY_THRESHOLD) {
return false;
}
handleHorizontalPageSwipe(deltaX < 0 ? 1 : -1);
return true;
}
});
screenRefresh.setOnTouchListener((v, event) -> {
if (conversationPagerGestureDetector != null) {
conversationPagerGestureDetector.onTouchEvent(event);
}
return false;
});
}
private void applyInitialTab(@Nullable Intent intent) {
@@ -400,6 +441,9 @@ public class MainActivity extends AppCompatActivity {
}
private void setActiveTab(String tab, boolean fromUser) {
if (!"conversations".equals(tab)) {
exitConversationSelectionMode();
}
activeTab = tab;
if (fromUser) {
userSelectedTab = true;
@@ -437,7 +481,7 @@ public class MainActivity extends AppCompatActivity {
case "conversations":
default:
updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle());
configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false));
configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode));
renderConversationsRoot();
break;
}
@@ -474,19 +518,23 @@ public class MainActivity extends AppCompatActivity {
}
private void syncTopActionVisualState(boolean refreshing) {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing);
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode);
configureTopAction(action);
refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing);
}
private void handleTopAction() {
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false).actionKey;
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey;
if ("add_device".equals(actionKey)) {
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
return;
}
if ("create_group_chat".equals(actionKey)) {
startActivity(new Intent(this, GroupCreateActivity.class));
if ("select_conversations".equals(actionKey)) {
enterConversationSelectionMode();
return;
}
if ("cancel_select_conversations".equals(actionKey)) {
exitConversationSelectionMode();
return;
}
refreshCurrentTab();
@@ -495,6 +543,9 @@ public class MainActivity extends AppCompatActivity {
private void renderConversationsRoot() {
screenContent.removeAllViews();
screenContent.addView(buildConversationSearchInput());
if (conversationSelectionMode) {
appendConversationSelectionControls();
}
JSONArray filteredConversations = filterConversationItems(conversationsData, conversationSearchQuery);
if (filteredConversations == null || filteredConversations.length() == 0) {
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。"));
@@ -541,7 +592,21 @@ public class MainActivity extends AppCompatActivity {
screenContent.addView(BossUi.buildConversationRow(
this,
row,
conversationSelectionMode,
selectedConversationProjectIds.contains(projectId),
v -> {
if (conversationSelectionMode) {
if (!"single_device".equals(conversationType)) {
showMessage("只能选择线程会话发起群聊");
return;
}
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
toggleConversationSelection(projectId);
return;
}
if ("folder_archive".equals(conversationType)) {
if (folderKey.isEmpty()) {
showMessage("缺少 folderKey");
@@ -560,6 +625,142 @@ public class MainActivity extends AppCompatActivity {
}
}
private void appendConversationSelectionControls() {
screenContent.addView(buildSelectionSummaryView());
Button cancelButton = BossUi.buildMiniActionButton(this, "取消", false);
cancelButton.setOnClickListener(v -> exitConversationSelectionMode());
Button createButton = BossUi.buildMiniActionButton(this, "发起群聊", true);
createButton.setEnabled(selectedConversationProjectIds.size() >= 2);
createButton.setOnClickListener(v -> createStandaloneGroupChatFromSelection());
screenContent.addView(BossUi.buildInlineActionRow(this, cancelButton, createButton));
}
private LinearLayout buildSelectionSummaryView() {
LinearLayout summaryWrap = new LinearLayout(this);
summaryWrap.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.leftMargin = BossUi.dp(this, 16);
params.rightMargin = BossUi.dp(this, 16);
params.bottomMargin = BossUi.dp(this, 10);
summaryWrap.setLayoutParams(params);
int count = selectedConversationProjectIds.size();
if (count > 0) {
TextView selectedView = new TextView(this);
selectedView.setText("已选 " + count + " 个线程");
selectedView.setTextSize(13);
selectedView.setTextColor(getColor(R.color.boss_text_primary));
summaryWrap.addView(selectedView);
}
if (count < 2) {
TextView hintView = new TextView(this);
hintView.setText("至少选择 2 个线程");
hintView.setTextSize(12);
hintView.setTextColor(getColor(R.color.boss_text_muted));
if (count > 0) {
hintView.setPadding(0, BossUi.dp(this, 4), 0, 0);
}
summaryWrap.addView(hintView);
}
return summaryWrap;
}
private void enterConversationSelectionMode() {
conversationSelectionMode = true;
selectedConversationProjectIds.clear();
syncTopActionVisualState(screenRefresh.isRefreshing());
renderConversationsRoot();
}
private void exitConversationSelectionMode() {
if (!conversationSelectionMode && selectedConversationProjectIds.isEmpty()) {
return;
}
conversationSelectionMode = false;
selectedConversationProjectIds.clear();
syncTopActionVisualState(screenRefresh.isRefreshing());
if ("conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) {
renderConversationsRoot();
}
}
private void toggleConversationSelection(String projectId) {
if (selectedConversationProjectIds.contains(projectId)) {
selectedConversationProjectIds.remove(projectId);
} else {
selectedConversationProjectIds.add(projectId);
}
renderConversationsRoot();
}
private void createStandaloneGroupChatFromSelection() {
if (selectedConversationProjectIds.size() < 2) {
showMessage("至少选择 2 个线程");
return;
}
List<String> snapshot = new ArrayList<>(selectedConversationProjectIds);
startRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
JSONArray memberProjectIds = new JSONArray();
for (String projectId : snapshot) {
memberProjectIds.put(projectId);
}
payload.put("memberProjectIds", memberProjectIds);
BossApiClient.ApiResponse response = apiClient.createStandaloneGroupChat(payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONObject project = response.json.optJSONObject("project");
if (project == null) {
throw new IllegalStateException("GROUP_CHAT_PROJECT_MISSING");
}
String createdProjectId = project.optString("id", "");
if (createdProjectId.isEmpty()) {
throw new IllegalStateException("GROUP_CHAT_PROJECT_ID_MISSING");
}
String createdProjectName = project.optString("name", "群聊");
runOnUiThread(() -> {
startRefreshing(false);
exitConversationSelectionMode();
showMessage("群聊已创建");
openProject(createdProjectId, createdProjectName);
refreshCurrentTab();
});
} catch (Exception error) {
runOnUiThread(() -> {
startRefreshing(false);
showMessage("创建失败:" + error.getMessage());
syncTopActionVisualState(false);
renderConversationsRoot();
});
}
});
}
private void handleHorizontalPageSwipe(int direction) {
String[] order = new String[] {"conversations", "devices", "me"};
int currentIndex = 0;
for (int i = 0; i < order.length; i++) {
if (order[i].equals(activeTab)) {
currentIndex = i;
break;
}
}
int nextIndex = currentIndex + direction;
if (nextIndex < 0 || nextIndex >= order.length) {
return;
}
setActiveTab(order[nextIndex], true);
}
private EditText buildConversationSearchInput() {
EditText input = BossUi.buildInput(this, "搜索项目或线程", false);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(

View File

@@ -176,7 +176,7 @@ public final class WechatSurfaceMapper {
}
JSONObject indicator = source.optJSONObject("contextBudgetIndicator");
if (indicator == null || !indicator.optBoolean("visible", false)) {
return null;
return "上下文稳定";
}
String level = indicator.optString("level", "safe");
int percent = indicator.optInt("percent", -1);
@@ -199,7 +199,7 @@ public final class WechatSurfaceMapper {
}
JSONObject indicator = source.optJSONObject("contextBudgetIndicator");
if (indicator == null || !indicator.optBoolean("visible", false)) {
return null;
return "safe";
}
return indicator.optString("level", null);
}
@@ -210,25 +210,32 @@ public final class WechatSurfaceMapper {
}
JSONObject indicator = source.optJSONObject("contextBudgetIndicator");
if (indicator == null || !indicator.optBoolean("visible", false)) {
return -1;
return 0;
}
int remainingPercent = indicator.optInt("percent", -1);
if (remainingPercent < 0) {
return -1;
return 0;
}
return Math.max(0, Math.min(100, 100 - remainingPercent));
}
private static boolean hasContextIndicator(JSONObject source) {
return resolveContextUsagePercent(source) >= 0;
return true;
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing) {
return rootTopAction(activeTab, refreshing, false);
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode) {
if ("devices".equals(activeTab)) {
return new RootTopAction("+添加", true, false, "add_device");
}
if ("conversations".equals(activeTab)) {
return new RootTopAction("+", false, true, "create_group_chat");
if (selectionMode) {
return new RootTopAction("取消", false, false, "cancel_select_conversations");
}
return new RootTopAction("选择", false, false, "select_conversations");
}
return new RootTopAction(refreshing ? "同步中" : "刷新", false, false, "refresh");
}

View File

@@ -0,0 +1,116 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityConversationSelectionTest {
@Test
public void conversationsSelectionMode_requiresAtLeastTwoSelectionsForGroupChat() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
assertTrue(ReflectionHelpers.getField(activity, "conversationSelectionMode"));
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "发起群聊"));
assertTrue(viewTreeContainsText(content, "至少选择 2 个线程"));
ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-1"));
content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "已选 1 个线程"));
assertTrue(viewTreeContainsText(content, "至少选择 2 个线程"));
ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-2"));
content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "已选 2 个线程"));
assertFalse(viewTreeContainsText(content, "至少选择 2 个线程"));
}
@Test
public void selectionModeRowsRenderSelectorInTrailingArea() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
LinearLayout content = activity.findViewById(R.id.screen_content);
View row = content.getChildAt(3);
assertTrue("多选模式应显示单选圆点", viewTreeContainsContentDescription(row, "未选中会话"));
}
private static JSONArray buildConversations() throws Exception {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-1")
.put("projectTitle", "Boss 移动控制台")
.put("threadTitle", "Boss 移动控制台")
.put("folderLabel", "Boss")
.put("lastMessagePreview", "线程链路正常")
.put("latestReplyLabel", "09:41")
.put("conversationType", "single_device"))
.put(new JSONObject()
.put("projectId", "thread-2")
.put("projectTitle", "硬件审计协作")
.put("threadTitle", "硬件审计协作")
.put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "检查摄像头供电链路")
.put("latestReplyLabel", "09:42")
.put("conversationType", "single_device"));
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
CharSequence description = root.getContentDescription();
if (expectedText.contentEquals(description)) {
return true;
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsContentDescription(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,32 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivitySwipeNavigationTest {
@Test
public void horizontalSwipeMovesBetweenRootTabs() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "handleHorizontalPageSwipe",
ReflectionHelpers.ClassParameter.from(int.class, 1));
assertEquals("devices", ReflectionHelpers.getField(activity, "activeTab"));
ReflectionHelpers.callInstanceMethod(activity, "handleHorizontalPageSwipe",
ReflectionHelpers.ClassParameter.from(int.class, 1));
assertEquals("me", ReflectionHelpers.getField(activity, "activeTab"));
ReflectionHelpers.callInstanceMethod(activity, "handleHorizontalPageSwipe",
ReflectionHelpers.ClassParameter.from(int.class, -1));
assertEquals("devices", ReflectionHelpers.getField(activity, "activeTab"));
}
}

View File

@@ -62,9 +62,24 @@ public class WechatSurfaceMapperConversationStatusTest {
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertNull(row.contextStatusLabel);
assertNull(row.contextStatusLevel);
assertEquals(-1, row.contextUsagePercent);
assertEquals("上下文稳定", row.contextStatusLabel);
assertEquals("safe", row.contextStatusLevel);
assertEquals(0, row.contextUsagePercent);
assertEquals(true, row.contextIndicatorVisible);
}
@Test
public void toConversationRow_usesSafeContextDefaultsWhenIndicatorMissing() {
JSONObject item = new StubJSONObject()
.withString("threadTitle", "北区试产线回归")
.withInt("activityIconCount", 0);
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("上下文稳定", row.contextStatusLabel);
assertEquals("safe", row.contextStatusLevel);
assertEquals(0, row.contextUsagePercent);
assertEquals(true, row.contextIndicatorVisible);
}
private static final class StubJSONObject extends JSONObject {

View File

@@ -8,13 +8,23 @@ import org.junit.Test;
public class WechatSurfaceMapperTopActionTest {
@Test
public void rootTopAction_usesPlusForConversations() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("conversations", false);
public void rootTopAction_usesSelectionForConversations() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("conversations", false, false);
assertEquals("+", action.label);
assertEquals("选择", action.label);
assertFalse(action.primaryStyle);
assertTrue(action.compactStyle);
assertEquals("create_group_chat", action.actionKey);
assertFalse(action.compactStyle);
assertEquals("select_conversations", action.actionKey);
}
@Test
public void rootTopAction_usesCancelDuringConversationSelection() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("conversations", false, true);
assertEquals("取消", action.label);
assertFalse(action.primaryStyle);
assertFalse(action.compactStyle);
assertEquals("cancel_select_conversations", action.actionKey);
}
@Test