diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index 4675640..65941a1 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -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()) { diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index c4ff538..d2f07b8 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -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); + })); } } diff --git a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java index eb358dd..59821e0 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -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; } } diff --git a/android/app/src/test/java/com/hyzq/boss/BossUiConversationRowTest.java b/android/app/src/test/java/com/hyzq/boss/BossUiConversationRowTest.java index a36b6c2..450dd04 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossUiConversationRowTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossUiConversationRowTest.java @@ -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) { diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java new file mode 100644 index 0000000..a4c183a --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java @@ -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; + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java index 6095406..f5f0304 100644 --- a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java +++ b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java @@ -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 {