feat: add conversation quick actions fan menu

This commit is contained in:
kris
2026-04-04 02:19:27 +08:00
parent 17dca04b6f
commit 9d19163b0d
6 changed files with 388 additions and 19 deletions

View File

@@ -10,6 +10,7 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
@@ -65,6 +66,11 @@ public class MainActivity extends AppCompatActivity {
private EditText topSearchInput;
private Button searchButton;
private Button refreshButton;
private View conversationQuickActionsOverlay;
private View conversationQuickActionsScrim;
private View quickActionAddDevice;
private View quickActionScan;
private View quickActionGroupChat;
private Button tabConversations;
private Button tabDevices;
private Button tabMe;
@@ -89,6 +95,7 @@ public class MainActivity extends AppCompatActivity {
private boolean pinnedConversationsCollapsed = false;
private boolean conversationSelectionMode = false;
private boolean conversationSearchMode = false;
private boolean conversationQuickActionsVisible = false;
private boolean conversationAutoRefreshArmed = false;
private boolean conversationAutoRefreshEnabled = false;
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
@@ -136,6 +143,10 @@ public class MainActivity extends AppCompatActivity {
exitConversationSearchMode(true);
return;
}
if (contentPanel.getVisibility() == View.VISIBLE && conversationQuickActionsVisible) {
hideConversationQuickActions(true);
return;
}
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
setActiveTab("conversations", false);
persistLastRootTab("conversations");
@@ -191,6 +202,11 @@ public class MainActivity extends AppCompatActivity {
topSearchInput = findViewById(R.id.top_search_input);
searchButton = findViewById(R.id.search_button);
refreshButton = findViewById(R.id.refresh_button);
conversationQuickActionsOverlay = findViewById(R.id.conversation_quick_actions_overlay);
conversationQuickActionsScrim = findViewById(R.id.conversation_quick_actions_scrim);
quickActionAddDevice = findViewById(R.id.quick_action_add_device);
quickActionScan = findViewById(R.id.quick_action_scan);
quickActionGroupChat = findViewById(R.id.quick_action_group_chat);
tabConversations = findViewById(R.id.tab_conversations);
tabDevices = findViewById(R.id.tab_devices);
tabMe = findViewById(R.id.tab_me);
@@ -221,6 +237,19 @@ public class MainActivity extends AppCompatActivity {
}
});
refreshButton.setOnClickListener(v -> handleTopAction());
conversationQuickActionsScrim.setOnClickListener(v -> hideConversationQuickActions(true));
quickActionAddDevice.setOnClickListener(v -> {
hideConversationQuickActions(false);
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
});
quickActionScan.setOnClickListener(v -> {
hideConversationQuickActions(false);
showMessage("扫一扫即将接入");
});
quickActionGroupChat.setOnClickListener(v -> {
hideConversationQuickActions(false);
enterConversationSelectionMode();
});
tabConversations.setOnClickListener(v -> setActiveTab("conversations", true));
tabDevices.setOnClickListener(v -> setActiveTab("devices", true));
tabMe.setOnClickListener(v -> setActiveTab("me", true));
@@ -478,6 +507,7 @@ public class MainActivity extends AppCompatActivity {
if (!"conversations".equals(tab)) {
exitConversationSelectionMode();
exitConversationSearchMode(false);
hideConversationQuickActions(false);
}
activeTab = tab;
if (fromUser) {
@@ -612,7 +642,7 @@ public class MainActivity extends AppCompatActivity {
exitConversationSelectionMode();
return;
}
enterConversationSelectionMode();
toggleConversationQuickActions();
return;
}
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey;
@@ -640,8 +670,12 @@ public class MainActivity extends AppCompatActivity {
appendConversationSelectionControls(items);
}
JSONArray filteredConversations = filterConversationItems(conversationsData, conversationSearchQuery);
if (conversationSelectionMode) {
filteredConversations = filterManualGroupSelectableConversationItems(filteredConversations);
}
if (filteredConversations == null || filteredConversations.length() == 0) {
items.add(() -> BossUi.buildEmptyCard(this, "当前没有会话数据。"));
String emptyText = conversationSelectionMode ? "当前没有可发起群聊的线程。" : "当前没有会话数据。";
items.add(() -> BossUi.buildEmptyCard(this, emptyText));
showListPage(items);
return;
}
@@ -722,15 +756,15 @@ public class MainActivity extends AppCompatActivity {
private void appendConversationSelectionControls(List<RootListItem> items) {
items.add(() -> buildSelectionSummaryView());
items.add(() -> {
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());
items.add(() -> BossUi.buildInlineActionRow(this, cancelButton, createButton));
return BossUi.buildInlineActionRow(this, cancelButton, createButton);
});
}
private LinearLayout buildSelectionSummaryView() {
@@ -768,6 +802,7 @@ public class MainActivity extends AppCompatActivity {
private void enterConversationSelectionMode() {
exitConversationSearchMode(false);
hideConversationQuickActions(false);
conversationSelectionMode = true;
selectedConversationProjectIds.clear();
syncTopActionVisualState(screenRefresh.isRefreshing());
@@ -790,6 +825,7 @@ public class MainActivity extends AppCompatActivity {
if (!"conversations".equals(activeTab)) {
return;
}
hideConversationQuickActions(false);
conversationSearchMode = true;
syncTopActionVisualState(screenRefresh.isRefreshing());
topSearchInput.post(() -> {
@@ -900,6 +936,128 @@ public class MainActivity extends AppCompatActivity {
return filtered;
}
static JSONArray filterManualGroupSelectableConversationItems(@Nullable JSONArray source) {
if (source == null) {
return null;
}
JSONArray filtered = new JSONArray();
for (int i = 0; i < source.length(); i++) {
JSONObject item = source.optJSONObject(i);
if (GroupCreateActivity.isEligibleForManualGroupSelection(item, null)) {
filtered.put(item);
}
}
return filtered;
}
private void toggleConversationQuickActions() {
if (conversationQuickActionsVisible) {
hideConversationQuickActions(true);
return;
}
showConversationQuickActions();
}
private void showConversationQuickActions() {
if (conversationQuickActionsOverlay == null || conversationQuickActionsVisible
|| !"conversations".equals(activeTab) || conversationSelectionMode || conversationSearchMode) {
return;
}
conversationQuickActionsVisible = true;
conversationQuickActionsOverlay.setVisibility(View.VISIBLE);
conversationQuickActionsOverlay.bringToFront();
conversationQuickActionsScrim.setAlpha(0f);
prepareConversationQuickAction(quickActionAddDevice);
prepareConversationQuickAction(quickActionScan);
prepareConversationQuickAction(quickActionGroupChat);
conversationQuickActionsScrim.animate()
.alpha(1f)
.setDuration(160L)
.setInterpolator(new AccelerateDecelerateInterpolator())
.start();
animateConversationQuickAction(quickActionAddDevice, -BossUi.dp(this, 12), BossUi.dp(this, 84), 0L);
animateConversationQuickAction(quickActionScan, -BossUi.dp(this, 90), BossUi.dp(this, 50), 24L);
animateConversationQuickAction(quickActionGroupChat, -BossUi.dp(this, 172), BossUi.dp(this, 14), 48L);
}
private void hideConversationQuickActions(boolean animated) {
if (conversationQuickActionsOverlay == null) {
return;
}
if (!conversationQuickActionsVisible && conversationQuickActionsOverlay.getVisibility() != View.VISIBLE) {
return;
}
conversationQuickActionsVisible = false;
if (!animated) {
conversationQuickActionsOverlay.setVisibility(View.GONE);
conversationQuickActionsScrim.setAlpha(0f);
resetConversationQuickAction(quickActionAddDevice);
resetConversationQuickAction(quickActionScan);
resetConversationQuickAction(quickActionGroupChat);
return;
}
conversationQuickActionsScrim.animate()
.alpha(0f)
.setDuration(140L)
.setInterpolator(new AccelerateDecelerateInterpolator())
.start();
collapseConversationQuickAction(quickActionAddDevice, 0L);
collapseConversationQuickAction(quickActionScan, 20L);
collapseConversationQuickAction(quickActionGroupChat, 40L);
conversationQuickActionsOverlay.postDelayed(() -> {
if (!conversationQuickActionsVisible) {
conversationQuickActionsOverlay.setVisibility(View.GONE);
resetConversationQuickAction(quickActionAddDevice);
resetConversationQuickAction(quickActionScan);
resetConversationQuickAction(quickActionGroupChat);
}
}, 190L);
}
private void prepareConversationQuickAction(View actionView) {
actionView.setVisibility(View.VISIBLE);
actionView.setAlpha(0f);
actionView.setScaleX(0.86f);
actionView.setScaleY(0.86f);
actionView.setTranslationX(0f);
actionView.setTranslationY(0f);
}
private void animateConversationQuickAction(View actionView, float translationX, float translationY, long delayMs) {
actionView.animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.translationX(translationX)
.translationY(translationY)
.setStartDelay(delayMs)
.setDuration(180L)
.setInterpolator(new AccelerateDecelerateInterpolator())
.start();
}
private void collapseConversationQuickAction(View actionView, long delayMs) {
actionView.animate()
.alpha(0f)
.scaleX(0.88f)
.scaleY(0.88f)
.translationX(0f)
.translationY(0f)
.setStartDelay(delayMs)
.setDuration(140L)
.setInterpolator(new AccelerateDecelerateInterpolator())
.start();
}
private void resetConversationQuickAction(View actionView) {
actionView.animate().cancel();
actionView.setAlpha(0f);
actionView.setScaleX(0.86f);
actionView.setScaleY(0.86f);
actionView.setTranslationX(0f);
actionView.setTranslationY(0f);
}
static boolean matchesConversationQuery(JSONObject item, String rawQuery) {
if (item == null) {
return false;

View File

@@ -235,7 +235,7 @@ public final class WechatSurfaceMapper {
if (selectionMode) {
return new RootTopAction("取消", false, false, "cancel_select_conversations");
}
return new RootTopAction("+", false, true, "select_conversations");
return new RootTopAction("+", false, true, "open_conversation_quick_actions");
}
return new RootTopAction(refreshing ? "同步中" : "刷新", false, false, "refresh");
}

View File

@@ -251,4 +251,82 @@
</LinearLayout>
</LinearLayout>
<FrameLayout
android:id="@+id/conversation_quick_actions_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:focusable="true"
android:visibility="gone">
<View
android:id="@+id/conversation_quick_actions_scrim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0"
android:background="#33000000" />
<FrameLayout
android:id="@+id/conversation_quick_actions_anchor"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="22dp"
android:paddingRight="20dp">
<Button
android:id="@+id/quick_action_add_device"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingTop="10dp"
android:paddingRight="14dp"
android:paddingBottom="10dp"
android:text="添加设备"
android:textAllCaps="false"
android:textColor="@color/boss_text_primary"
android:textSize="14sp"
android:textStyle="bold"
android:visibility="invisible" />
<Button
android:id="@+id/quick_action_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingTop="10dp"
android:paddingRight="14dp"
android:paddingBottom="10dp"
android:text="扫一扫"
android:textAllCaps="false"
android:textColor="@color/boss_text_primary"
android:textSize="14sp"
android:textStyle="bold"
android:visibility="invisible" />
<Button
android:id="@+id/quick_action_group_chat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingTop="10dp"
android:paddingRight="14dp"
android:paddingBottom="10dp"
android:text="发起群聊"
android:textAllCaps="false"
android:textColor="@color/boss_text_primary"
android:textSize="14sp"
android:textStyle="bold"
android:visibility="invisible" />
</FrameLayout>
</FrameLayout>
</FrameLayout>

View File

@@ -46,17 +46,30 @@ public class GroupCreateActivityTest {
JSONObject threadConversation = new StubJSONObject()
.withString("projectId", "thread-1")
.withString("projectTitle", "线程一")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject groupConversation = new StubJSONObject()
.withString("projectId", "group-1")
.withString("projectTitle", "已有群聊")
.withString("conversationType", "group_chat")
.withBoolean("isGroup", true);
JSONObject sourceConversation = new StubJSONObject()
.withString("projectId", "source-1")
.withString("projectTitle", "来源线程")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject agentConversation = new StubJSONObject()
.withString("projectId", "master-agent")
.withString("projectTitle", "主Agent")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject consoleConversation = new StubJSONObject()
.withString("projectId", "console-1")
.withString("projectTitle", "BOSS移动控制台")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray("conversations", threadConversation, groupConversation, sourceConversation);
.withObjectArray("conversations", threadConversation, groupConversation, sourceConversation, agentConversation, consoleConversation);
java.util.List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, "source-1");
@@ -69,17 +82,25 @@ public class GroupCreateActivityTest {
JSONObject threadConversation = new StubJSONObject()
.withString("projectId", "thread-1")
.withString("projectTitle", "线程一")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject secondThreadConversation = new StubJSONObject()
.withString("projectId", "thread-2")
.withString("projectTitle", "线程二")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject groupConversation = new StubJSONObject()
.withString("projectId", "group-1")
.withString("projectTitle", "已有群聊")
.withString("conversationType", "group_chat")
.withBoolean("isGroup", true);
JSONObject auditConversation = new StubJSONObject()
.withString("projectId", "audit-1")
.withString("projectTitle", "硬件审计协作")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray("conversations", threadConversation, secondThreadConversation, groupConversation);
.withObjectArray("conversations", threadConversation, secondThreadConversation, groupConversation, auditConversation);
java.util.List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, null);
@@ -88,6 +109,70 @@ public class GroupCreateActivityTest {
assertEquals("thread-2", filtered.get(1).optString("projectId", ""));
}
@Test
public void collectSelectableConversationItems_filtersOutSystemManagedConversations() {
JSONObject agentConversation = new StubJSONObject()
.withString("projectId", "master-agent")
.withString("projectTitle", "主 Agent")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject auditConversation = new StubJSONObject()
.withString("projectId", "audit-1")
.withString("projectTitle", "硬件审计协作")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject consoleConversation = new StubJSONObject()
.withString("projectId", "console-1")
.withString("projectTitle", "BOSS移动控制台")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject workerConversation = new StubJSONObject()
.withString("projectId", "thread-7")
.withString("projectTitle", "树莓派查询线程")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray("conversations", agentConversation, auditConversation, consoleConversation, workerConversation);
List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, null);
assertEquals(1, filtered.size());
assertEquals("thread-7", filtered.get(0).optString("projectId", ""));
}
@Test
public void collectSelectableConversationItems_filtersOutSystemAutoJoinConversations() {
JSONObject allowedThreadConversation = new StubJSONObject()
.withString("projectId", "thread-1")
.withString("projectTitle", "查询树莓派二代")
.withBoolean("isGroup", false);
JSONObject masterAgentConversation = new StubJSONObject()
.withString("projectId", "thread-2")
.withString("projectTitle", "主Agent")
.withBoolean("isGroup", false);
JSONObject auditConversation = new StubJSONObject()
.withString("projectId", "thread-3")
.withString("projectTitle", "硬件审计协作")
.withBoolean("isGroup", false);
JSONObject consoleConversation = new StubJSONObject()
.withString("projectId", "thread-4")
.withString("projectTitle", "BOSS移动控制台")
.withBoolean("isGroup", false);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray(
"conversations",
allowedThreadConversation,
masterAgentConversation,
auditConversation,
consoleConversation
);
List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, null);
assertEquals(1, filtered.size());
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
}
@Test
public void reconcileSelectedProjectIds_keepsManualDeselectionWhenCandidatesStayTheSame() {
Set<String> previousCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");

View File

@@ -5,6 +5,7 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.view.View;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
@@ -31,7 +32,13 @@ public class MainActivityConversationSelectionTest {
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
Button actionButton = activity.findViewById(R.id.refresh_button);
actionButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(View.VISIBLE, activity.findViewById(R.id.conversation_quick_actions_overlay).getVisibility());
activity.findViewById(R.id.quick_action_group_chat).performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
assertTrue(ReflectionHelpers.getField(activity, "conversationSelectionMode"));
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
@@ -62,6 +69,23 @@ public class MainActivityConversationSelectionTest {
assertTrue("多选模式应显示单选圆点", viewTreeContainsContentDescription(row, "未选中会话"));
}
@Test
public void selectionMode_hidesSystemManagedConversations() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
assertFalse(recyclerContainsText(list, "主Agent"));
assertFalse(recyclerContainsText(list, "硬件审计协作"));
assertFalse(recyclerContainsText(list, "BOSS移动控制台"));
assertTrue(recyclerContainsText(list, "查询树莓派二代"));
assertTrue(recyclerContainsText(list, "Boss 线程修复"));
}
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
int viewType = adapter.getItemViewType(position);
@@ -84,20 +108,44 @@ public class MainActivityConversationSelectionTest {
private static JSONArray buildConversations() throws Exception {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-1")
.put("projectTitle", "Boss 移动控制台")
.put("threadTitle", "Boss 移动控制台")
.put("projectId", "master-agent")
.put("projectTitle", "主Agent")
.put("threadTitle", "主Agent")
.put("folderLabel", "Boss")
.put("lastMessagePreview", "线程链路正常")
.put("latestReplyLabel", "09:41")
.put("conversationType", "single_device"))
.put(new JSONObject()
.put("projectId", "thread-2")
.put("projectId", "audit-1")
.put("projectTitle", "硬件审计协作")
.put("threadTitle", "硬件审计协作")
.put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "检查摄像头供电链路")
.put("latestReplyLabel", "09:42")
.put("conversationType", "single_device"))
.put(new JSONObject()
.put("projectId", "console-1")
.put("projectTitle", "BOSS移动控制台")
.put("threadTitle", "BOSS移动控制台")
.put("folderLabel", "Boss")
.put("lastMessagePreview", "统一顶部按钮样式")
.put("latestReplyLabel", "09:43")
.put("conversationType", "single_device"))
.put(new JSONObject()
.put("projectId", "thread-1")
.put("projectTitle", "查询树莓派二代")
.put("threadTitle", "查询树莓派二代")
.put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "树莓派二代参数查询")
.put("latestReplyLabel", "09:44")
.put("conversationType", "single_device"))
.put(new JSONObject()
.put("projectId", "thread-2")
.put("projectTitle", "Boss 线程修复")
.put("threadTitle", "Boss 线程修复")
.put("folderLabel", "Boss")
.put("lastMessagePreview", "修复群聊回流")
.put("latestReplyLabel", "09:45")
.put("conversationType", "single_device"));
}

View File

@@ -14,7 +14,7 @@ public class WechatSurfaceMapperTopActionTest {
assertEquals("+", action.label);
assertFalse(action.primaryStyle);
assertTrue(action.compactStyle);
assertEquals("select_conversations", action.actionKey);
assertEquals("open_conversation_quick_actions", action.actionKey);
}
@Test