Messaging: Looply search hub with frequent contacts and nav inset

Redesign Search around frequent contacts, category counts, and recent chips, and keep the Messages/Automation pill on the left guideline without moving compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-26 05:36:08 +09:00
co-authored by Cursor
parent 88529c3634
commit a32250fe34
13 changed files with 762 additions and 163 deletions
@@ -138,6 +138,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
private TextView mHomeEditButton;
private ImageButton mHomePinnedDoneButton;
private boolean mHomePinnedEditMode;
private View mHomeNavDock;
private View mHomeNavMessages;
private View mHomeNavAutomate;
private View mHomeNavTabGap;
@@ -389,6 +390,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
mHomeMessagesTop = rootView.findViewById(R.id.home_messages_top);
mHomeTitle = rootView.findViewById(R.id.home_title);
mHomeAutomationPane = rootView.findViewById(R.id.home_automation_pane);
mHomeNavDock = rootView.findViewById(R.id.home_nav_dock);
mHomeNavMessages = rootView.findViewById(R.id.home_nav_messages);
bindHomeNavTab(mHomeNavMessages, R.drawable.ic_home_nav_messages,
R.string.home_nav_messages);
@@ -453,6 +455,23 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
applyHomeNavTabActiveSize(mHomeNavMessages, mHomeNavMessagesNaturalWidth);
applyHomeNavTabActiveSize(mHomeNavAutomate, mHomeNavAutomateNaturalWidth);
if (mHomeNavDock != null) {
mHomeNavDock.post(this::alignHomeNavDockToGuideline);
}
}
/** Slide only the Messages/Automation pill to the 20dp screen guideline; compose stays put. */
private void alignHomeNavDockToGuideline() {
if (mHomeNavDock == null || mRootView == null || mHomeNavDock.getWidth() <= 0) {
return;
}
final int guideline = getResources().getDimensionPixelSize(R.dimen.looply_screen_padding);
mHomeNavDock.setTranslationX(0f);
final int[] dockLoc = new int[2];
final int[] rootLoc = new int[2];
mHomeNavDock.getLocationOnScreen(dockLoc);
mRootView.getLocationOnScreen(rootLoc);
mHomeNavDock.setTranslationX(guideline - (dockLoc[0] - rootLoc[0]));
}
private void applyHomeNavTabActiveSize(final View tab, final int natural) {
@@ -16,29 +16,58 @@
package com.android.messaging.ui.home;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.speech.RecognizerIntent;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.DrawableRes;
import androidx.annotation.IdRes;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.content.ContextCompat;
import com.android.messaging.R;
import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.ContactIconView;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.ImeUtil;
import com.android.messaging.util.UriUtil;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
/** Home-style message search entry point with quick filter tiles. */
import java.util.ArrayList;
import java.util.List;
/** Home-style message search hub with frequent contacts, categories, and recent queries. */
public class HomeSearchActivity extends BugleActionBarActivity {
private static final int FREQUENT_LIMIT = 4;
private static final int VOICE_SEARCH_REQUEST = 71;
private EditText mSearchField;
private ImageButton mClearButton;
private ImageButton mVoiceButton;
private LinearLayout mFrequentSection;
private LinearLayout mFrequentRow;
private View mRecentSection;
private ChipGroup mRecentGroup;
@Override
protected void onCreate(final Bundle savedInstanceState) {
@@ -49,12 +78,40 @@ public class HomeSearchActivity extends BugleActionBarActivity {
}
HomeSubHeader.bind(this, R.string.home_search_title, v -> finish());
HomeSubHeader.bindAction(this, R.string.home_search_reset, v -> resetSearch());
mSearchField = findViewById(R.id.home_search_field);
mClearButton = findViewById(R.id.home_search_clear);
mVoiceButton = findViewById(R.id.home_search_voice);
mFrequentSection = findViewById(R.id.home_search_frequent_section);
mFrequentRow = findViewById(R.id.home_search_frequent_row);
mRecentSection = findViewById(R.id.home_search_recent_section);
mRecentGroup = findViewById(R.id.home_search_recent_group);
if (mClearButton != null) {
mClearButton.setOnClickListener(v -> mSearchField.setText(""));
}
if (mVoiceButton != null) {
mVoiceButton.setOnClickListener(v -> startVoiceSearch());
}
final View seeAll = findViewById(R.id.home_search_frequent_see_all);
if (seeAll != null) {
seeAll.setOnClickListener(v -> HomeToast.show(this,
getString(R.string.home_search_coming_soon,
getString(R.string.home_search_frequent_title))));
}
final View clearHistory = findViewById(R.id.home_search_clear_history);
if (clearHistory != null) {
clearHistory.setOnClickListener(v -> {
HomeSearchHistoryStore.clear(this);
bindRecentSearches();
});
}
final TextView categoryCount = findViewById(R.id.home_search_categories_count);
if (categoryCount != null) {
categoryCount.setText(getString(R.string.home_search_categories_count, 8));
}
if (mSearchField != null) {
mSearchField.addTextChangedListener(new TextWatcher() {
@Override
@@ -69,7 +126,7 @@ public class HomeSearchActivity extends BugleActionBarActivity {
@Override
public void afterTextChanged(final Editable s) {
updateClearButton();
updateFieldActions();
}
});
mSearchField.setOnEditorActionListener((v, actionId, event) -> {
@@ -79,28 +136,31 @@ public class HomeSearchActivity extends BugleActionBarActivity {
mSearchField.requestFocus();
mSearchField.post(() -> ImeUtil.get().showImeKeyboard(this, mSearchField));
}
updateClearButton();
updateFieldActions();
bindFilter(R.id.home_search_filter_unread, R.drawable.ic_home_menu_unread,
R.string.home_search_filter_unread);
R.string.home_search_filter_unread, 0);
bindFilter(R.id.home_search_filter_saved, R.drawable.ic_home_menu_person,
R.string.home_search_filter_saved);
R.string.home_search_filter_saved, 0);
bindFilter(R.id.home_search_filter_unknown, R.drawable.ic_home_menu_spam,
R.string.home_search_filter_unknown);
R.string.home_search_filter_unknown, 0);
bindFilter(R.id.home_search_filter_starred, R.drawable.ic_home_star_line,
R.string.home_search_filter_starred);
R.string.home_search_filter_starred, StarredMessagesStore.count(this));
bindFilter(R.id.home_search_filter_images, R.drawable.ic_home_image_line,
R.string.home_search_filter_images);
R.string.home_search_filter_images, 0);
bindFilter(R.id.home_search_filter_videos, R.drawable.ic_home_video_line,
R.string.home_search_filter_videos);
R.string.home_search_filter_videos, 0);
bindFilter(R.id.home_search_filter_places, R.drawable.ic_home_place_line,
R.string.home_search_filter_places);
R.string.home_search_filter_places, 0);
bindFilter(R.id.home_search_filter_links, R.drawable.ic_home_link_line,
R.string.home_search_filter_links);
R.string.home_search_filter_links, 0);
bindRecentSearches();
loadConversationStats();
}
private void bindFilter(@IdRes final int tileId, @DrawableRes final int iconRes,
@StringRes final int labelRes) {
@StringRes final int labelRes, final int count) {
final View tile = findViewById(tileId);
if (tile == null) {
return;
@@ -113,25 +173,232 @@ public class HomeSearchActivity extends BugleActionBarActivity {
if (label != null) {
label.setText(labelRes);
}
setFilterCount(tile, count);
tile.setOnClickListener(v -> HomeToast.show(this,
getString(R.string.home_search_coming_soon, getString(labelRes))));
}
private void updateClearButton() {
if (mClearButton == null || mSearchField == null) {
private void setFilterCount(final View tile, final int count) {
final TextView countView = tile.findViewById(R.id.home_search_filter_count);
if (countView == null) {
return;
}
mClearButton.setVisibility(
TextUtils.isEmpty(mSearchField.getText()) ? View.GONE : View.VISIBLE);
countView.setText(String.valueOf(Math.max(0, count)));
countView.setTextColor(ContextCompat.getColor(this,
count > 0 ? R.color.home_accent : R.color.home_text_secondary));
}
private void loadConversationStats() {
new Thread(() -> {
int unread = 0;
int saved = 0;
int unknown = 0;
final List<ConversationListItemData> frequent = new ArrayList<>();
Cursor cursor = null;
try {
cursor = getContentResolver().query(
MessagingContentProvider.CONVERSATIONS_URI, null, null, null,
ConversationColumns.SORT_TIMESTAMP + " DESC");
if (cursor != null) {
while (cursor.moveToNext()) {
final ConversationListItemData item = new ConversationListItemData();
item.bind(cursor);
if (item.getIsArchived()) {
continue;
}
if (!item.getIsRead()) {
unread++;
}
if (!item.getIsGroup()) {
if (item.getParticipantContactId() > 0) {
saved++;
} else {
unknown++;
}
}
if (frequent.size() < FREQUENT_LIMIT) {
frequent.add(item);
}
}
}
} catch (final Exception ignored) {
} finally {
if (cursor != null) {
cursor.close();
}
}
final int unreadCount = unread;
final int savedCount = saved;
final int unknownCount = unknown;
runOnUiThread(() -> {
if (isFinishing()) {
return;
}
applyFilterCount(R.id.home_search_filter_unread, unreadCount);
applyFilterCount(R.id.home_search_filter_saved, savedCount);
applyFilterCount(R.id.home_search_filter_unknown, unknownCount);
bindFrequentContacts(frequent);
});
}, "home-search-stats").start();
}
private void applyFilterCount(@IdRes final int tileId, final int count) {
final View tile = findViewById(tileId);
if (tile != null) {
setFilterCount(tile, count);
}
}
private void bindFrequentContacts(final List<ConversationListItemData> items) {
if (mFrequentSection == null || mFrequentRow == null) {
return;
}
mFrequentRow.removeAllViews();
if (items == null || items.isEmpty()) {
mFrequentSection.setVisibility(View.GONE);
return;
}
mFrequentSection.setVisibility(View.VISIBLE);
final LayoutInflater inflater = getLayoutInflater();
for (final ConversationListItemData item : items) {
final View cell = inflater.inflate(R.layout.home_search_frequent_item, mFrequentRow,
false);
final ContactIconView avatar = cell.findViewById(R.id.home_search_frequent_avatar);
final TextView name = cell.findViewById(R.id.home_search_frequent_name);
if (avatar != null) {
avatar.setImageResourceUri(frequentAvatarUri(item),
item.getParticipantContactId(),
item.getParticipantLookupKey(),
item.getOtherParticipantNormalizedDestination());
avatar.setImageClickHandlerDisabled(true);
}
if (name != null) {
name.setText(item.getName());
}
cell.setOnClickListener(v -> UIIntents.get().launchConversationActivity(
this, item.getConversationId(), null));
mFrequentRow.addView(cell);
}
}
private void bindRecentSearches() {
if (mRecentSection == null || mRecentGroup == null) {
return;
}
mRecentGroup.removeAllViews();
final List<String> items = HomeSearchHistoryStore.list(this);
if (items.isEmpty()) {
mRecentSection.setVisibility(View.GONE);
return;
}
mRecentSection.setVisibility(View.VISIBLE);
final int bg = ContextCompat.getColor(this, R.color.home_row_bg);
final int stroke = ContextCompat.getColor(this, R.color.home_divider);
final int text = ContextCompat.getColor(this, R.color.home_text_primary);
final int close = ContextCompat.getColor(this, R.color.home_text_secondary);
for (final String query : items) {
final Chip chip = new Chip(this, null, com.google.android.material.R.attr.chipStyle);
chip.setText(query);
chip.setCheckable(false);
chip.setClickable(true);
chip.setCloseIconVisible(true);
chip.setChipBackgroundColor(ColorStateList.valueOf(bg));
chip.setChipStrokeColor(ColorStateList.valueOf(stroke));
chip.setChipStrokeWidth(getResources().getDisplayMetrics().density);
chip.setChipCornerRadius(999f);
chip.setTextColor(text);
chip.setCloseIconTint(ColorStateList.valueOf(close));
chip.setEnsureMinTouchTargetSize(false);
chip.setOnClickListener(v -> {
mSearchField.setText(query);
mSearchField.setSelection(query.length());
submitSearch();
});
chip.setOnCloseIconClickListener(v -> {
HomeSearchHistoryStore.remove(this, query);
bindRecentSearches();
});
mRecentGroup.addView(chip);
}
}
private static Uri frequentAvatarUri(final ConversationListItemData item) {
Uri stored = null;
if (!TextUtils.isEmpty(item.getIcon())) {
try {
stored = Uri.parse(item.getIcon());
} catch (final RuntimeException ignored) {
stored = null;
}
}
if (stored != null && AvatarUriUtil.isAvatarUri(stored)) {
return stored;
}
final Uri photo = (stored != null && UriUtil.isLocalResourceUri(stored)) ? stored : null;
return AvatarUriUtil.createAvatarUri(photo, item.getName(),
item.getOtherParticipantNormalizedDestination(),
item.getParticipantLookupKey());
}
private void updateFieldActions() {
final boolean empty = mSearchField == null || TextUtils.isEmpty(mSearchField.getText());
if (mClearButton != null) {
mClearButton.setVisibility(empty ? View.GONE : View.VISIBLE);
}
if (mVoiceButton != null) {
mVoiceButton.setVisibility(empty ? View.VISIBLE : View.GONE);
}
}
private void submitSearch() {
if (mSearchField == null || TextUtils.isEmpty(mSearchField.getText())) {
return;
}
final String query = mSearchField.getText().toString().trim();
HomeSearchHistoryStore.add(this, query);
bindRecentSearches();
ImeUtil.get().hideImeKeyboard(this, mSearchField);
HomeToast.show(this, getString(R.string.home_search_coming_soon,
mSearchField.getText().toString()));
HomeToast.show(this, getString(R.string.home_search_coming_soon, query));
}
private void resetSearch() {
if (mSearchField != null) {
mSearchField.setText("");
}
HomeSearchHistoryStore.clear(this);
bindRecentSearches();
updateFieldActions();
}
private void startVoiceSearch() {
final Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
intent.putExtra(RecognizerIntent.EXTRA_PROMPT, getString(R.string.home_search_field_hint));
try {
startActivityForResult(intent, VOICE_SEARCH_REQUEST);
} catch (final Exception ignored) {
HomeToast.show(this, getString(R.string.home_search_coming_soon,
getString(R.string.home_search_voice)));
}
}
@Override
protected void onActivityResult(final int requestCode, final int resultCode,
@Nullable final Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != VOICE_SEARCH_REQUEST || resultCode != RESULT_OK || data == null
|| mSearchField == null) {
return;
}
final ArrayList<String> results =
data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS);
if (results == null || results.isEmpty()) {
return;
}
mSearchField.setText(results.get(0));
mSearchField.setSelection(mSearchField.getText().length());
submitSearch();
}
@Override
@@ -0,0 +1,69 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Recent search queries for the home search hub. */
public final class HomeSearchHistoryStore {
private static final String PREFS_NAME = "home_search_history";
private static final String KEY_QUERIES = "queries";
private static final String SEPARATOR = "\n";
private static final int MAX_ITEMS = 10;
private HomeSearchHistoryStore() {
}
public static List<String> list(final Context context) {
final String raw = prefs(context).getString(KEY_QUERIES, "");
if (TextUtils.isEmpty(raw)) {
return Collections.emptyList();
}
final List<String> items = new ArrayList<>();
for (final String part : raw.split(SEPARATOR, -1)) {
if (!TextUtils.isEmpty(part)) {
items.add(part);
}
}
return items;
}
public static void add(final Context context, final String query) {
final String trimmed = query == null ? "" : query.trim();
if (TextUtils.isEmpty(trimmed)) {
return;
}
final List<String> items = new ArrayList<>(list(context));
items.remove(trimmed);
items.add(0, trimmed);
while (items.size() > MAX_ITEMS) {
items.remove(items.size() - 1);
}
save(context, items);
}
public static void remove(final Context context, final String query) {
final List<String> items = new ArrayList<>(list(context));
if (items.remove(query)) {
save(context, items);
}
}
public static void clear(final Context context) {
prefs(context).edit().remove(KEY_QUERIES).apply();
}
private static void save(final Context context, final List<String> items) {
prefs(context).edit().putString(KEY_QUERIES, TextUtils.join(SEPARATOR, items)).apply();
}
private static SharedPreferences prefs(final Context context) {
return context.getApplicationContext().getSharedPreferences(PREFS_NAME,
Context.MODE_PRIVATE);
}
}
@@ -48,6 +48,17 @@ public final class HomeSubHeader {
}
}
public static void bindAction(final Activity activity, @StringRes final int actionResId,
final View.OnClickListener onAction) {
final TextView action = activity.findViewById(R.id.home_sub_header_action);
if (action == null) {
return;
}
action.setText(actionResId);
action.setVisibility(View.VISIBLE);
action.setOnClickListener(onAction);
}
public static void bindBackOnly(final Activity activity,
final View.OnClickListener onBack) {
final ImageButton back = activity.findViewById(R.id.home_sub_header_back);
@@ -32,6 +32,10 @@ public final class StarredMessagesStore {
private StarredMessagesStore() {
}
public static int count(final Context context) {
return ids(context).size();
}
public static boolean isStarred(final Context context, final String messageId) {
if (TextUtils.isEmpty(messageId)) {
return false;