feat: add conversation selection mode and swipe tabs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -392,10 +392,10 @@ function buildConversationItem(state: BossState, project: Project): Conversation
|
||||
},
|
||||
groupMembers,
|
||||
contextBudgetIndicator: {
|
||||
visible: Boolean(topThread),
|
||||
visible: true,
|
||||
style: "ring_percent",
|
||||
percent: topThread?.contextBudgetRemainingPct,
|
||||
level: topThread?.contextBudgetLevel,
|
||||
percent: topThread?.contextBudgetRemainingPct ?? 100,
|
||||
level: topThread?.contextBudgetLevel ?? "safe",
|
||||
},
|
||||
contextBudgetSourceNodeId: topThread?.nodeId,
|
||||
contextBudgetUpdatedAt: topThread?.capturedAt,
|
||||
@@ -534,10 +534,10 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] {
|
||||
primary: device?.avatar ?? latestItem.avatar.primary,
|
||||
},
|
||||
contextBudgetIndicator: {
|
||||
visible: Boolean(topContextItem?.contextBudgetIndicator.visible),
|
||||
visible: true,
|
||||
style: "ring_percent",
|
||||
percent: topContextItem?.contextBudgetIndicator.percent,
|
||||
level: topContextItem?.contextBudgetIndicator.level,
|
||||
percent: topContextItem?.contextBudgetIndicator.percent ?? 100,
|
||||
level: topContextItem?.contextBudgetIndicator.level ?? "safe",
|
||||
},
|
||||
mustFinishBeforeCompaction: items.some((item) => item.mustFinishBeforeCompaction),
|
||||
});
|
||||
|
||||
@@ -168,4 +168,34 @@ test("conversation items expose context status while keeping idle activity silen
|
||||
assert.equal(threadConversation.contextBudgetIndicator.level, "urgent");
|
||||
assert.equal(threadConversation.activityIconCount, 0);
|
||||
assert.equal(masterAgent.activityIconCount, 0);
|
||||
assert.equal(masterAgent.contextBudgetIndicator.visible, true);
|
||||
assert.equal(masterAgent.contextBudgetIndicator.percent, 100);
|
||||
assert.equal(masterAgent.contextBudgetIndicator.level, "safe");
|
||||
});
|
||||
|
||||
test("conversation items keep a safe context ring even when no thread snapshot exists", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
state.projects.push(
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"single-thread-no-context",
|
||||
"Talking",
|
||||
"talking",
|
||||
"调试回归",
|
||||
"thread-no-context",
|
||||
"2026-03-30T11:20:00+08:00",
|
||||
),
|
||||
);
|
||||
state.threadContextSnapshots = [];
|
||||
|
||||
const items = getConversationHomeItems(state);
|
||||
const directThread = items.find((item) => item.projectId === "single-thread-no-context");
|
||||
|
||||
assert.ok(directThread);
|
||||
assert.equal(directThread?.contextBudgetIndicator.visible, true);
|
||||
assert.equal(directThread?.contextBudgetIndicator.percent, 100);
|
||||
assert.equal(directThread?.contextBudgetIndicator.level, "safe");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user