feat: polish pinned conversations and context ring

This commit is contained in:
kris
2026-04-03 10:48:34 +08:00
parent da55071a99
commit 4d2d567bf9
6 changed files with 340 additions and 35 deletions

View File

@@ -5,7 +5,10 @@ import android.animation.ObjectAnimator;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.text.TextUtils;
@@ -38,6 +41,9 @@ public final class BossUi {
private static final int DEVICE_STATUS_ONLINE = Color.parseColor("#18B85A");
private static final int DEVICE_STATUS_ABNORMAL = Color.parseColor("#FF5A5A");
private static final int DEVICE_STATUS_OFFLINE = Color.parseColor("#A7AFB7");
private static final int PINNED_ROW_BG = Color.parseColor("#FFF7F7F7");
private static final int CONTEXT_RING_TRACK = Color.parseColor("#FFD7D7D7");
private static final int CONTEXT_RING_BG = Color.parseColor("#FFF4F4F4");
private BossUi() {}
@@ -570,7 +576,7 @@ public final class BossUi {
params.bottomMargin = dp(context, 1);
card.setLayoutParams(params);
card.setPadding(dp(context, 16), dp(context, 12), dp(context, 16), dp(context, 12));
card.setBackgroundColor(Color.WHITE);
card.setBackgroundColor(row.pinnedConversation ? PINNED_ROW_BG : Color.WHITE);
card.setElevation(0f);
if (listener != null) {
card.setClickable(true);
@@ -630,7 +636,7 @@ public final class BossUi {
LinearLayout.LayoutParams.WRAP_CONTENT
));
if (!TextUtils.isEmpty(row.topPinnedLabel)) {
if (!row.pinnedConversation && !TextUtils.isEmpty(row.topPinnedLabel)) {
TextView pinnedView = new TextView(context);
pinnedView.setText(row.topPinnedLabel);
pinnedView.setTextSize(11);
@@ -649,7 +655,7 @@ public final class BossUi {
timeView.setText(TextUtils.isEmpty(row.timeLabel) ? "--:--" : row.timeLabel);
timeView.setTextSize(12);
timeView.setTextColor(context.getColor(R.color.boss_text_muted));
timeView.setPadding(0, dp(context, TextUtils.isEmpty(row.topPinnedLabel) ? 2 : 8), 0, 0);
timeView.setPadding(0, dp(context, TextUtils.isEmpty(row.topPinnedLabel) || row.pinnedConversation ? 2 : 8), 0, 0);
timeView.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
@@ -698,23 +704,73 @@ public final class BossUi {
trailingColumn.addView(activityWrap);
}
if (!TextUtils.isEmpty(row.contextStatusLabel)) {
TextView contextView = new TextView(context);
contextView.setText(row.contextStatusLabel);
contextView.setTextSize(11);
contextView.setTypeface(Typeface.DEFAULT_BOLD);
contextView.setTextColor(resolveConversationContextColor(context, row.contextStatusLevel));
contextView.setPadding(0, dp(context, 8), 0, 0);
contextView.setMaxLines(1);
contextView.setEllipsize(TextUtils.TruncateAt.END);
contextView.setGravity(Gravity.END);
trailingColumn.addView(contextView);
if (row.contextIndicatorVisible) {
FrameLayout ringWrap = new FrameLayout(context);
LinearLayout.LayoutParams ringWrapParams = new LinearLayout.LayoutParams(dp(context, 28), dp(context, 28));
ringWrapParams.topMargin = dp(context, 8);
ringWrap.setLayoutParams(ringWrapParams);
ringWrap.setBackground(createRoundedBackground(CONTEXT_RING_BG, dp(context, 14)));
ringWrap.setContentDescription(TextUtils.isEmpty(row.contextStatusLabel)
? "上下文使用量"
: "上下文使用量:" + row.contextStatusLabel);
View ring = buildContextUsageRing(
context,
row.contextUsagePercent,
row.contextStatusLevel,
row.contextMustFinish
);
FrameLayout.LayoutParams ringParams = new FrameLayout.LayoutParams(dp(context, 18), dp(context, 18), Gravity.CENTER);
ring.setLayoutParams(ringParams);
ringWrap.addView(ring);
trailingColumn.addView(ringWrap);
}
card.addView(trailingColumn);
return card;
}
public static LinearLayout buildConversationSectionHeader(
Context context,
String title,
String actionLabel,
@Nullable View.OnClickListener listener
) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.leftMargin = dp(context, 16);
params.rightMargin = dp(context, 16);
params.bottomMargin = dp(context, 8);
row.setLayoutParams(params);
row.setPadding(0, dp(context, 6), 0, dp(context, 4));
if (listener != null) {
row.setClickable(true);
row.setFocusable(true);
row.setOnClickListener(listener);
}
TextView titleView = new TextView(context);
titleView.setText(title);
titleView.setTextSize(13);
titleView.setTypeface(Typeface.DEFAULT_BOLD);
titleView.setTextColor(context.getColor(R.color.boss_text_muted));
LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
titleView.setLayoutParams(titleParams);
row.addView(titleView);
TextView actionView = new TextView(context);
actionView.setText(actionLabel);
actionView.setTextSize(12);
actionView.setTextColor(context.getColor(R.color.boss_text_soft));
row.addView(actionView);
return row;
}
public static LinearLayout buildEmptyCard(Context context, String text) {
return buildCard(context, "暂无内容", text, "下拉或点击顶部刷新按钮重试。");
}
@@ -1166,6 +1222,10 @@ public final class BossUi {
return Math.round(value * context.getResources().getDisplayMetrics().density);
}
public static int dp(Context context, float value) {
return Math.round(value * context.getResources().getDisplayMetrics().density);
}
private static View buildConversationAvatar(Context context, WechatSurfaceMapper.ConversationRow row) {
if (!row.isGroup) {
return buildAvatarCircle(
@@ -1362,6 +1422,62 @@ public final class BossUi {
}
}
private static View buildContextUsageRing(
Context context,
int usagePercent,
@Nullable String level,
boolean mustFinish
) {
final int clampedUsage = Math.max(0, Math.min(100, usagePercent));
final int ringColor = resolveContextRingColor(level, mustFinish);
final float strokeWidth = dp(context, 2.4f);
return new View(context) {
private final Paint trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint progressPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF arcRect = new RectF();
{
trackPaint.setStyle(Paint.Style.STROKE);
trackPaint.setStrokeCap(Paint.Cap.ROUND);
trackPaint.setStrokeWidth(strokeWidth);
trackPaint.setColor(CONTEXT_RING_TRACK);
progressPaint.setStyle(Paint.Style.STROKE);
progressPaint.setStrokeCap(Paint.Cap.ROUND);
progressPaint.setStrokeWidth(strokeWidth);
progressPaint.setColor(ringColor);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float inset = strokeWidth;
arcRect.set(inset, inset, getWidth() - inset, getHeight() - inset);
canvas.drawArc(arcRect, -90, 360, false, trackPaint);
canvas.drawArc(arcRect, -90, (360f * clampedUsage) / 100f, false, progressPaint);
}
};
}
private static int resolveContextRingColor(@Nullable String level, boolean mustFinish) {
if (mustFinish) {
return Color.parseColor("#D94B4B");
}
if (TextUtils.isEmpty(level)) {
return Color.parseColor("#8E8E93");
}
switch (level) {
case "critical":
return Color.parseColor("#D94B4B");
case "urgent":
return Color.parseColor("#8A8A8A");
case "watch":
return Color.parseColor("#9A9A9A");
default:
return Color.parseColor("#B0B0B0");
}
}
private static String firstLetter(String value) {
String text = value == null ? "" : value.trim();
if (text.isEmpty()) {

View File

@@ -64,6 +64,7 @@ public class MainActivity extends AppCompatActivity {
private @Nullable String boundDeviceId;
private @Nullable String boundDeviceName;
private String conversationSearchQuery = "";
private boolean pinnedConversationsCollapsed = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -464,9 +465,39 @@ public class MainActivity extends AppCompatActivity {
return;
}
JSONArray pinnedItems = new JSONArray();
JSONArray regularItems = new JSONArray();
for (int i = 0; i < filteredConversations.length(); i++) {
JSONObject item = filteredConversations.optJSONObject(i);
if (item == null) continue;
if (!item.optString("topPinnedLabel", "").isEmpty()) {
pinnedItems.put(item);
} else {
regularItems.put(item);
}
}
if (pinnedItems.length() > 0) {
screenContent.addView(BossUi.buildConversationSectionHeader(
this,
"置顶会话",
pinnedConversationsCollapsed ? "展开" : "收起",
v -> {
pinnedConversationsCollapsed = !pinnedConversationsCollapsed;
renderConversationsRoot();
}
));
if (!pinnedConversationsCollapsed) {
appendConversationRows(pinnedItems);
}
}
appendConversationRows(regularItems);
}
private void appendConversationRows(JSONArray items) {
for (int i = 0; i < items.length(); i++) {
JSONObject item = items.optJSONObject(i);
if (item == null) continue;
String projectId = item.optString("projectId", "");
String conversationType = item.optString("conversationType", "");
String folderKey = item.optString("folderKey", "");
@@ -475,21 +506,21 @@ public class MainActivity extends AppCompatActivity {
this,
row,
v -> {
if ("folder_archive".equals(conversationType)) {
if (folderKey.isEmpty()) {
showMessage("缺少 folderKey");
return;
}
openConversationFolder(folderKey, row.threadTitle);
return;
}
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
String projectName = row.threadTitle.isEmpty() ? "未命名会话" : row.threadTitle;
openProject(projectId, projectName);
}));
if ("folder_archive".equals(conversationType)) {
if (folderKey.isEmpty()) {
showMessage("缺少 folderKey");
return;
}
openConversationFolder(folderKey, row.threadTitle);
return;
}
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
String projectName = row.threadTitle.isEmpty() ? "未命名会话" : row.threadTitle;
openProject(projectId, projectName);
}));
}
}

View File

@@ -54,20 +54,25 @@ public final class WechatSurfaceMapper {
}
JSONObject avatar = source.optJSONObject("avatar");
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
String pinnedLabel = source.optString("topPinnedLabel", "");
return new ConversationRow(
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))),
source.optString("folderLabel", ""),
source.optString("lastMessagePreview", source.optString("preview", "")),
source.optString("timeLabel", source.optString("latestReplyLabel", "")),
source.optInt("unreadCount", 0),
source.optString("topPinnedLabel", ""),
pinnedLabel,
source.optInt("activityIconCount", 0),
isGroup,
isGroup ? "" : avatar == null ? "" : avatar.optString("primary", ""),
isGroup ? "" : avatar == null ? "" : avatar.optString("secondary", ""),
groupAvatarMembers.toArray(new GroupAvatarMember[0]),
pinnedLabel != null && !pinnedLabel.isEmpty(),
buildContextStatusLabel(source),
resolveContextStatusLevel(source)
resolveContextStatusLevel(source),
resolveContextUsagePercent(source),
hasContextIndicator(source),
source.optBoolean("mustFinishBeforeCompaction", false)
);
}
@@ -199,6 +204,25 @@ public final class WechatSurfaceMapper {
return indicator.optString("level", null);
}
private static int resolveContextUsagePercent(JSONObject source) {
if (source.optBoolean("mustFinishBeforeCompaction", false)) {
return 100;
}
JSONObject indicator = source.optJSONObject("contextBudgetIndicator");
if (indicator == null || !indicator.optBoolean("visible", false)) {
return -1;
}
int remainingPercent = indicator.optInt("percent", -1);
if (remainingPercent < 0) {
return -1;
}
return Math.max(0, Math.min(100, 100 - remainingPercent));
}
private static boolean hasContextIndicator(JSONObject source) {
return resolveContextUsagePercent(source) >= 0;
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing) {
if ("devices".equals(activeTab)) {
return new RootTopAction("+添加", true, false, "add_device");
@@ -380,8 +404,12 @@ public final class WechatSurfaceMapper {
public final String avatarPrimary;
public final String avatarSecondary;
public final GroupAvatarMember[] groupAvatarMembers;
public final boolean pinnedConversation;
public final String contextStatusLabel;
public final String contextStatusLevel;
public final int contextUsagePercent;
public final boolean contextIndicatorVisible;
public final boolean contextMustFinish;
public ConversationRow(
String threadTitle,
@@ -408,8 +436,12 @@ public final class WechatSurfaceMapper {
avatarPrimary,
avatarSecondary,
groupAvatarMembers,
false,
null,
null
null,
-1,
false,
false
);
}
@@ -425,8 +457,12 @@ public final class WechatSurfaceMapper {
String avatarPrimary,
String avatarSecondary,
GroupAvatarMember[] groupAvatarMembers,
boolean pinnedConversation,
String contextStatusLabel,
String contextStatusLevel
String contextStatusLevel,
int contextUsagePercent,
boolean contextIndicatorVisible,
boolean contextMustFinish
) {
this.threadTitle = threadTitle;
this.folderLabel = folderLabel;
@@ -439,8 +475,12 @@ public final class WechatSurfaceMapper {
this.avatarPrimary = avatarPrimary;
this.avatarSecondary = avatarSecondary;
this.groupAvatarMembers = groupAvatarMembers == null ? new GroupAvatarMember[0] : groupAvatarMembers;
this.pinnedConversation = pinnedConversation;
this.contextStatusLabel = contextStatusLabel;
this.contextStatusLevel = contextStatusLevel;
this.contextUsagePercent = contextUsagePercent;
this.contextIndicatorVisible = contextIndicatorVisible;
this.contextMustFinish = contextMustFinish;
}
}

View File

@@ -1,9 +1,11 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
@@ -80,15 +82,49 @@ public class BossUiConversationRowTest {
"M",
"W",
new WechatSurfaceMapper.GroupAvatarMember[0],
false,
"上下文紧张 34%",
"urgent"
"urgent",
66,
true,
false
);
LinearLayout rowView = BossUi.buildConversationRow(context, row, null);
LinearLayout trailingColumn = (LinearLayout) rowView.getChildAt(2);
assertTrue(viewTreeContainsText(trailingColumn, "上下文紧张 34%"));
assertEquals("空闲会话不应再渲染活动点", 2, trailingColumn.getChildCount());
assertFalse("右下角应改成环形上下文状态,而不是文字", viewTreeContainsText(trailingColumn, "上下文紧张 34%"));
}
@Test
public void buildConversationRow_usesSubtlePinnedBackgroundWithoutPinnedBadge() {
Context context = RuntimeEnvironment.getApplication();
WechatSurfaceMapper.ConversationRow row = new WechatSurfaceMapper.ConversationRow(
"主 Agent",
"主控线程",
"正在观察多个任务",
"09:26",
0,
"置顶",
0,
false,
"M",
"A",
new WechatSurfaceMapper.GroupAvatarMember[0],
true,
null,
null,
-1,
false,
false
);
LinearLayout rowView = BossUi.buildConversationRow(context, row, null);
assertTrue("置顶会话背景应略深于普通白底", rowView.getBackground() instanceof ColorDrawable);
assertEquals(0xFFF7F7F7, ((ColorDrawable) rowView.getBackground()).getColor());
assertFalse("置顶会话不应再显示右侧“置顶”文字", viewTreeContainsText(rowView, "置顶"));
}
private static boolean viewTreeContainsText(View root, String expectedText) {

View File

@@ -0,0 +1,77 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
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 MainActivityPinnedConversationsTest {
@Test
public void renderConversationsRoot_groupsPinnedConversationsAndTogglesCollapse() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject()
.put("projectId", "p1")
.put("projectTitle", "主 Agent")
.put("threadTitle", "主 Agent")
.put("folderLabel", "主控线程")
.put("topPinnedLabel", "置顶")
.put("lastMessagePreview", "正在观察")
.put("latestReplyLabel", "09:40"))
.put(new JSONObject()
.put("projectId", "p2")
.put("projectTitle", "Boss 移动控制台")
.put("threadTitle", "Boss 移动控制台")
.put("folderLabel", "归档确认")
.put("lastMessagePreview", "线程链路正常")
.put("latestReplyLabel", "09:41")));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "renderConversationsRoot");
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "置顶会话"));
assertTrue(viewTreeContainsText(content, "收起"));
assertTrue(viewTreeContainsText(content, "主 Agent"));
assertTrue(viewTreeContainsText(content, "Boss 移动控制台"));
View pinnedHeader = content.getChildAt(1);
pinnedHeader.performClick();
assertEquals(true, ReflectionHelpers.getField(activity, "pinnedConversationsCollapsed"));
assertTrue(viewTreeContainsText(content, "展开"));
assertTrue("收起后普通会话仍应保留", viewTreeContainsText(content, "Boss 移动控制台"));
}
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;
}
}

View File

@@ -27,6 +27,8 @@ public class WechatSurfaceMapperConversationStatusTest {
assertEquals(0, row.activityIconCount);
assertEquals("上下文紧张 34%", row.contextStatusLabel);
assertEquals("urgent", row.contextStatusLevel);
assertEquals(66, row.contextUsagePercent);
assertEquals(true, row.contextIndicatorVisible);
}
@Test
@@ -44,6 +46,8 @@ public class WechatSurfaceMapperConversationStatusTest {
assertEquals("必须收尾", row.contextStatusLabel);
assertEquals("critical", row.contextStatusLevel);
assertEquals(100, row.contextUsagePercent);
assertEquals(true, row.contextMustFinish);
}
@Test
@@ -60,6 +64,7 @@ public class WechatSurfaceMapperConversationStatusTest {
assertNull(row.contextStatusLabel);
assertNull(row.contextStatusLevel);
assertEquals(-1, row.contextUsagePercent);
}
private static final class StubJSONObject extends JSONObject {