feat: move conversation search into header mode

This commit is contained in:
kris
2026-04-04 01:50:06 +08:00
parent 062cab8e9a
commit 17dca04b6f
6 changed files with 223 additions and 66 deletions

View File

@@ -61,6 +61,9 @@ public class MainActivity extends AppCompatActivity {
private Button backButton; private Button backButton;
private TextView topTitle; private TextView topTitle;
private TextView topSubtitle; private TextView topSubtitle;
private LinearLayout topTitleGroup;
private EditText topSearchInput;
private Button searchButton;
private Button refreshButton; private Button refreshButton;
private Button tabConversations; private Button tabConversations;
private Button tabDevices; private Button tabDevices;
@@ -85,6 +88,7 @@ public class MainActivity extends AppCompatActivity {
private String conversationSearchQuery = ""; private String conversationSearchQuery = "";
private boolean pinnedConversationsCollapsed = false; private boolean pinnedConversationsCollapsed = false;
private boolean conversationSelectionMode = false; private boolean conversationSelectionMode = false;
private boolean conversationSearchMode = false;
private boolean conversationAutoRefreshArmed = false; private boolean conversationAutoRefreshArmed = false;
private boolean conversationAutoRefreshEnabled = false; private boolean conversationAutoRefreshEnabled = false;
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>(); private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
@@ -128,6 +132,10 @@ public class MainActivity extends AppCompatActivity {
@Override @Override
public void onBackPressed() { public void onBackPressed() {
if (contentPanel.getVisibility() == View.VISIBLE && conversationSearchMode) {
exitConversationSearchMode(true);
return;
}
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) { if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
setActiveTab("conversations", false); setActiveTab("conversations", false);
persistLastRootTab("conversations"); persistLastRootTab("conversations");
@@ -179,6 +187,9 @@ public class MainActivity extends AppCompatActivity {
backButton = findViewById(R.id.back_button); backButton = findViewById(R.id.back_button);
topTitle = findViewById(R.id.top_title); topTitle = findViewById(R.id.top_title);
topSubtitle = findViewById(R.id.top_subtitle); topSubtitle = findViewById(R.id.top_subtitle);
topTitleGroup = findViewById(R.id.top_title_group);
topSearchInput = findViewById(R.id.top_search_input);
searchButton = findViewById(R.id.search_button);
refreshButton = findViewById(R.id.refresh_button); refreshButton = findViewById(R.id.refresh_button);
tabConversations = findViewById(R.id.tab_conversations); tabConversations = findViewById(R.id.tab_conversations);
tabDevices = findViewById(R.id.tab_devices); tabDevices = findViewById(R.id.tab_devices);
@@ -199,6 +210,16 @@ public class MainActivity extends AppCompatActivity {
private void bindActions() { private void bindActions() {
loginButton.setOnClickListener(v -> performAutoLogin()); loginButton.setOnClickListener(v -> performAutoLogin());
backButton.setVisibility(View.GONE); backButton.setVisibility(View.GONE);
backButton.setOnClickListener(v -> {
if (conversationSearchMode) {
exitConversationSearchMode(true);
}
});
searchButton.setOnClickListener(v -> {
if ("conversations".equals(activeTab) && !conversationSelectionMode) {
enterConversationSearchMode();
}
});
refreshButton.setOnClickListener(v -> handleTopAction()); refreshButton.setOnClickListener(v -> handleTopAction());
tabConversations.setOnClickListener(v -> setActiveTab("conversations", true)); tabConversations.setOnClickListener(v -> setActiveTab("conversations", true));
tabDevices.setOnClickListener(v -> setActiveTab("devices", true)); tabDevices.setOnClickListener(v -> setActiveTab("devices", true));
@@ -220,6 +241,26 @@ public class MainActivity extends AppCompatActivity {
renderCurrentTab(); renderCurrentTab();
updateConversationAutoRefresh(); updateConversationAutoRefresh();
}); });
topSearchInput.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable editable) {
if (!conversationSearchMode) {
return;
}
String nextQuery = editable == null ? "" : editable.toString();
if (nextQuery.equals(conversationSearchQuery)) {
return;
}
conversationSearchQuery = nextQuery;
renderConversationsRoot();
}
});
} }
private void applyInitialTab(@Nullable Intent intent) { private void applyInitialTab(@Nullable Intent intent) {
@@ -436,6 +477,7 @@ public class MainActivity extends AppCompatActivity {
private void setActiveTab(String tab, boolean fromUser) { private void setActiveTab(String tab, boolean fromUser) {
if (!"conversations".equals(tab)) { if (!"conversations".equals(tab)) {
exitConversationSelectionMode(); exitConversationSelectionMode();
exitConversationSearchMode(false);
} }
activeTab = tab; activeTab = tab;
if (fromUser) { if (fromUser) {
@@ -465,18 +507,18 @@ public class MainActivity extends AppCompatActivity {
switch (activeTab) { switch (activeTab) {
case "devices": case "devices":
updateHeader("设备", ""); updateHeader("设备", "");
configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false)); configureRootHeaderActions();
renderDevicesRoot(); renderDevicesRoot();
break; break;
case "me": case "me":
updateHeader("我的", ""); updateHeader("我的", "");
configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false)); configureRootHeaderActions();
renderMeRoot(); renderMeRoot();
break; break;
case "conversations": case "conversations":
default: default:
updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle()); updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle());
configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode)); configureRootHeaderActions();
renderConversationsRoot(); renderConversationsRoot();
break; break;
} }
@@ -512,13 +554,67 @@ public class MainActivity extends AppCompatActivity {
); );
} }
private void configureRootHeaderActions() {
if ("conversations".equals(activeTab)) {
configureConversationHeaderActions();
return;
}
topTitleGroup.setVisibility(View.VISIBLE);
topSearchInput.setVisibility(View.GONE);
backButton.setVisibility(View.GONE);
searchButton.setVisibility(View.GONE);
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false);
refreshButton.setVisibility(View.VISIBLE);
configureTopAction(action);
}
private void configureConversationHeaderActions() {
if (conversationSearchMode) {
topTitleGroup.setVisibility(View.GONE);
topSearchInput.setVisibility(View.VISIBLE);
backButton.setVisibility(View.VISIBLE);
backButton.setText("取消");
searchButton.setVisibility(View.GONE);
refreshButton.setVisibility(View.GONE);
if (!conversationSearchQuery.equals(topSearchInput.getText().toString())) {
topSearchInput.setText(conversationSearchQuery);
topSearchInput.setSelection(topSearchInput.getText().length());
}
return;
}
topTitleGroup.setVisibility(View.VISIBLE);
topSearchInput.setVisibility(View.GONE);
backButton.setVisibility(View.GONE);
searchButton.setVisibility(conversationSelectionMode ? View.GONE : View.VISIBLE);
refreshButton.setVisibility(View.VISIBLE);
BossUi.applyTopActionButtonStyle(this, searchButton, BossUi.TopActionButtonStyle.COMPACT_ICON);
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode);
configureTopAction(action);
}
private void syncTopActionVisualState(boolean refreshing) { private void syncTopActionVisualState(boolean refreshing) {
if ("conversations".equals(activeTab)) {
configureConversationHeaderActions();
refreshButton.setEnabled(true);
return;
}
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode); WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode);
configureTopAction(action); configureTopAction(action);
refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing); refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing);
} }
private void handleTopAction() { private void handleTopAction() {
if ("conversations".equals(activeTab)) {
if (conversationSearchMode) {
return;
}
if (conversationSelectionMode) {
exitConversationSelectionMode();
return;
}
enterConversationSelectionMode();
return;
}
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey; String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey;
if ("add_device".equals(actionKey)) { if ("add_device".equals(actionKey)) {
startActivity(new Intent(this, DeviceEnrollmentActivity.class)); startActivity(new Intent(this, DeviceEnrollmentActivity.class));
@@ -540,7 +636,6 @@ public class MainActivity extends AppCompatActivity {
return; return;
} }
List<RootListItem> items = new ArrayList<>(); List<RootListItem> items = new ArrayList<>();
items.add(() -> buildConversationSearchInput());
if (conversationSelectionMode) { if (conversationSelectionMode) {
appendConversationSelectionControls(items); appendConversationSelectionControls(items);
} }
@@ -672,6 +767,7 @@ public class MainActivity extends AppCompatActivity {
} }
private void enterConversationSelectionMode() { private void enterConversationSelectionMode() {
exitConversationSearchMode(false);
conversationSelectionMode = true; conversationSelectionMode = true;
selectedConversationProjectIds.clear(); selectedConversationProjectIds.clear();
syncTopActionVisualState(screenRefresh.isRefreshing()); syncTopActionVisualState(screenRefresh.isRefreshing());
@@ -690,6 +786,34 @@ public class MainActivity extends AppCompatActivity {
} }
} }
private void enterConversationSearchMode() {
if (!"conversations".equals(activeTab)) {
return;
}
conversationSearchMode = true;
syncTopActionVisualState(screenRefresh.isRefreshing());
topSearchInput.post(() -> {
topSearchInput.requestFocus();
topSearchInput.setSelection(topSearchInput.getText().length());
});
}
private void exitConversationSearchMode(boolean clearQuery) {
if (!conversationSearchMode && (!clearQuery || conversationSearchQuery.isEmpty())) {
return;
}
boolean queryChanged = clearQuery && !conversationSearchQuery.isEmpty();
conversationSearchMode = false;
if (clearQuery) {
conversationSearchQuery = "";
topSearchInput.setText("");
}
syncTopActionVisualState(screenRefresh != null && screenRefresh.isRefreshing());
if (queryChanged && "conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) {
renderConversationsRoot();
}
}
private void toggleConversationSelection(String projectId) { private void toggleConversationSelection(String projectId) {
if (selectedConversationProjectIds.contains(projectId)) { if (selectedConversationProjectIds.contains(projectId)) {
selectedConversationProjectIds.remove(projectId); selectedConversationProjectIds.remove(projectId);
@@ -755,37 +879,6 @@ public class MainActivity extends AppCompatActivity {
setActiveTab(tab, true); setActiveTab(tab, true);
} }
private EditText buildConversationSearchInput() {
EditText input = BossUi.buildInput(this, "搜索项目或线程", false);
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);
input.setLayoutParams(params);
input.setText(conversationSearchQuery);
input.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable editable) {
String nextQuery = editable == null ? "" : editable.toString();
if (nextQuery.equals(conversationSearchQuery)) {
return;
}
conversationSearchQuery = nextQuery;
renderConversationsRoot();
}
});
return input;
}
static JSONArray filterConversationItems(@Nullable JSONArray source, @Nullable String rawQuery) { static JSONArray filterConversationItems(@Nullable JSONArray source, @Nullable String rawQuery) {
if (source == null) { if (source == null) {
return null; return null;

View File

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

View File

@@ -111,6 +111,7 @@
android:visibility="gone" /> android:visibility="gone" />
<LinearLayout <LinearLayout
android:id="@+id/top_title_group"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
@@ -138,6 +139,40 @@
android:visibility="gone" /> android:visibility="gone" />
</LinearLayout> </LinearLayout>
<EditText
android:id="@+id/top_search_input"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:hint="搜索项目或线程"
android:imeOptions="actionSearch"
android:inputType="text"
android:paddingLeft="14dp"
android:paddingTop="8dp"
android:paddingRight="14dp"
android:paddingBottom="8dp"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted"
android:textSize="15sp"
android:visibility="gone" />
<Button
android:id="@+id/search_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_secondary_button"
android:layout_marginRight="8dp"
android:minWidth="0dp"
android:paddingLeft="12dp"
android:paddingTop="8dp"
android:paddingRight="12dp"
android:paddingBottom="8dp"
android:text="⌕"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
<Button <Button
android:id="@+id/refresh_button" android:id="@+id/refresh_button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -148,7 +183,7 @@
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingRight="12dp" android:paddingRight="12dp"
android:paddingBottom="8dp" android:paddingBottom="8dp"
android:text="" android:text="+"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/boss_green" android:textColor="@color/boss_green"
android:textStyle="bold" /> android:textStyle="bold" />

View File

@@ -1,13 +1,13 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.view.View; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.FrameLayout; import android.widget.LinearLayout;
import androidx.recyclerview.widget.RecyclerView;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
@@ -46,34 +46,63 @@ public class MainActivityConversationSearchTest {
} }
@Test @Test
public void renderConversationsRootShowsSearchInputInsteadOfHintPill() throws Exception { public void conversationsHeader_usesSearchIconAndPlusButton() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray() ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
.put(new JSONObject()
.put("projectId", "p1")
.put("projectTitle", "500Gcode")
.put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "线程链路正常")
.put("latestReplyLabel", "09:40")));
ReflectionHelpers.callInstanceMethod(activity, "showContent"); ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "renderConversationsRoot"); ReflectionHelpers.callInstanceMethod(activity, "renderCurrentTab");
Button searchButton = activity.findViewById(R.id.search_button);
Button actionButton = activity.findViewById(R.id.refresh_button);
LinearLayout titleGroup = activity.findViewById(R.id.top_title_group);
EditText searchInput = activity.findViewById(R.id.top_search_input);
assertEquals("", String.valueOf(searchButton.getText()));
assertEquals("+", String.valueOf(actionButton.getText()));
assertEquals(LinearLayout.VISIBLE, titleGroup.getVisibility());
assertEquals(EditText.GONE, searchInput.getVisibility());
}
@Test
public void searchMode_keepsSameInputViewAndRetainsFocusWhileFiltering() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); Button searchButton = activity.findViewById(R.id.search_button);
View firstItem = buildRecyclerItem(list, 0); searchButton.performClick();
assertTrue(firstItem instanceof EditText); Shadows.shadowOf(activity.getMainLooper()).idle();
EditText input = (EditText) firstItem;
assertEquals("搜索项目或线程", String.valueOf(input.getHint())); EditText searchInput = activity.findViewById(R.id.top_search_input);
assertTrue(ReflectionHelpers.getField(activity, "conversationSearchMode"));
assertTrue(searchInput.isShown());
searchInput.requestFocus();
assertTrue(searchInput.isFocused());
searchInput.setText("树莓派");
Shadows.shadowOf(activity.getMainLooper()).idle();
EditText searchInputAfter = activity.findViewById(R.id.top_search_input);
assertSame(searchInput, searchInputAfter);
assertEquals("树莓派", searchInputAfter.getText().toString());
assertTrue(searchInputAfter.isFocused());
Button actionButton = activity.findViewById(R.id.refresh_button);
assertEquals(Button.GONE, actionButton.getVisibility());
assertFalse(activity.findViewById(R.id.search_button).isShown());
} }
private static View buildRecyclerItem(RecyclerView recyclerView, int position) { private static JSONArray buildConversations() throws Exception {
RecyclerView.Adapter adapter = recyclerView.getAdapter(); return new JSONArray()
int viewType = adapter.getItemViewType(position); .put(new JSONObject()
RecyclerView.ViewHolder holder = adapter.createViewHolder(recyclerView, viewType); .put("projectId", "p1")
adapter.bindViewHolder(holder, position); .put("projectTitle", "查询树莓派二代")
FrameLayout container = (FrameLayout) holder.itemView; .put("threadTitle", "查询树莓派二代")
return container.getChildAt(0); .put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "线程链路正常")
.put("latestReplyLabel", "09:40")
.put("conversationType", "single_device"));
} }
} }

View File

@@ -58,7 +58,7 @@ public class MainActivityConversationSelectionTest {
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode"); ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
View row = getRecyclerChild(list, 3); View row = getRecyclerChild(list, 2);
assertTrue("多选模式应显示单选圆点", viewTreeContainsContentDescription(row, "未选中会话")); assertTrue("多选模式应显示单选圆点", viewTreeContainsContentDescription(row, "未选中会话"));
} }

View File

@@ -11,9 +11,9 @@ public class WechatSurfaceMapperTopActionTest {
public void rootTopAction_usesSelectionForConversations() { public void rootTopAction_usesSelectionForConversations() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("conversations", false, false); WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("conversations", false, false);
assertEquals("选择", action.label); assertEquals("+", action.label);
assertFalse(action.primaryStyle); assertFalse(action.primaryStyle);
assertFalse(action.compactStyle); assertTrue(action.compactStyle);
assertEquals("select_conversations", action.actionKey); assertEquals("select_conversations", action.actionKey);
} }