Search now actually searches, yay

This commit is contained in:
Grishka 2022-03-31 17:49:54 +03:00
parent fa9112e117
commit c60bc253e5
27 changed files with 1046 additions and 122 deletions

View File

@ -14,13 +14,17 @@ import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.requests.notifications.GetNotifications;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.Status;
import java.io.IOException;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.function.Consumer;
import androidx.annotation.Nullable;
import me.grishka.appkit.api.Callback;
@ -29,7 +33,7 @@ import me.grishka.appkit.utils.WorkerThread;
public class CacheController{
private static final String TAG="CacheController";
private static final int DB_VERSION=1;
private static final int DB_VERSION=2;
private static final WorkerThread databaseThread=new WorkerThread("databaseThread");
private static final Handler uiHandler=new Handler(Looper.getMainLooper());
@ -91,24 +95,16 @@ public class CacheController{
}
private void putHomeTimeline(List<Status> posts, boolean clear){
cancelDelayedClose();
databaseThread.postRunnable(()->{
try{
SQLiteDatabase db=getOrOpenDatabase();
if(clear)
db.delete("home_timeline", null, null);
ContentValues values=new ContentValues(2);
for(Status s:posts){
values.put("id", s.id);
values.put("json", MastodonAPIController.gson.toJson(s));
db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
}catch(SQLiteException x){
Log.w(TAG, x);
}finally{
closeDelayed();
runOnDbThread((db)->{
if(clear)
db.delete("home_timeline", null, null);
ContentValues values=new ContentValues(2);
for(Status s:posts){
values.put("id", s.id);
values.put("json", MastodonAPIController.gson.toJson(s));
db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
}, 0);
});
}
public void getNotifications(String maxID, int count, boolean onlyMentions, boolean forceReload, Callback<List<Notification>> callback){
@ -157,26 +153,46 @@ public class CacheController{
}
private void putNotifications(List<Notification> notifications, boolean onlyMentions, boolean clear){
cancelDelayedClose();
databaseThread.postRunnable(()->{
try{
SQLiteDatabase db=getOrOpenDatabase();
String table=onlyMentions ? "notifications_mentions" : "notifications_all";
if(clear)
db.delete(table, null, null);
ContentValues values=new ContentValues(3);
for(Notification n:notifications){
values.put("id", n.id);
values.put("json", MastodonAPIController.gson.toJson(n));
values.put("type", n.type.ordinal());
db.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
}catch(SQLiteException x){
Log.w(TAG, x);
}finally{
closeDelayed();
runOnDbThread((db)->{
String table=onlyMentions ? "notifications_mentions" : "notifications_all";
if(clear)
db.delete(table, null, null);
ContentValues values=new ContentValues(3);
for(Notification n:notifications){
values.put("id", n.id);
values.put("json", MastodonAPIController.gson.toJson(n));
values.put("type", n.type.ordinal());
db.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
}, 0);
});
}
public void getRecentSearches(Consumer<List<SearchResult>> callback){
runOnDbThread((db)->{
try(Cursor cursor=db.query("recent_searches", new String[]{"json"}, null, null, null, null, "time DESC")){
List<SearchResult> results=new ArrayList<>();
while(cursor.moveToNext()){
SearchResult result=MastodonAPIController.gson.fromJson(cursor.getString(0), SearchResult.class);
result.postprocess();
results.add(result);
}
uiHandler.post(()->callback.accept(results));
}
});
}
public void putRecentSearch(SearchResult result){
runOnDbThread((db)->{
ContentValues values=new ContentValues(4);
values.put("id", result.getID());
values.put("json", MastodonAPIController.gson.toJson(result));
values.put("time", (int)(System.currentTimeMillis()/1000));
db.insertWithOnConflict("recent_searches", null, values, SQLiteDatabase.CONFLICT_REPLACE);
});
}
public void clearRecentSearches(){
runOnDbThread((db)->db.delete("recent_searches", null, null));
}
private void closeDelayed(){
@ -204,6 +220,26 @@ public class CacheController{
return db.getWritableDatabase();
}
private void runOnDbThread(DatabaseRunnable r){
runOnDbThread(r, null);
}
private void runOnDbThread(DatabaseRunnable r, Consumer<Exception> onError){
cancelDelayedClose();
databaseThread.postRunnable(()->{
try{
SQLiteDatabase db=getOrOpenDatabase();
r.run(db);
}catch(SQLiteException|IOException x){
Log.w(TAG, x);
if(onError!=null)
onError.accept(x);
}finally{
closeDelayed();
}
}, 0);
}
private class DatabaseHelper extends SQLiteOpenHelper{
public DatabaseHelper(){
@ -232,11 +268,28 @@ public class CacheController{
`flags` INTEGER NOT NULL DEFAULT 0,
`type` INTEGER NOT NULL
)""");
createRecentSearchesTable(db);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion){
if(oldVersion==1){
createRecentSearchesTable(db);
}
}
private void createRecentSearchesTable(SQLiteDatabase db){
db.execSQL("""
CREATE TABLE `recent_searches` (
`id` VARCHAR(50) NOT NULL PRIMARY KEY,
`json` TEXT NOT NULL,
`time` INTEGER NOT NULL
)""");
}
}
@FunctionalInterface
private interface DatabaseRunnable{
void run(SQLiteDatabase db) throws IOException;
}
}

View File

@ -4,11 +4,13 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.SearchResults;
public class GetSearchResults extends MastodonAPIRequest<SearchResults>{
public GetSearchResults(String query, Type type){
public GetSearchResults(String query, Type type, boolean resolve){
super(HttpMethod.GET, "/search", SearchResults.class);
addQueryParameter("q", query);
if(type!=null)
addQueryParameter("type", type.name().toLowerCase());
if(resolve)
addQueryParameter("resolve", "true");
}
@Override

View File

@ -11,7 +11,6 @@ import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.Toolbar;
import org.joinmastodon.android.R;
@ -586,20 +585,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
@Override
public void scrollToTop(){
if(list.getChildCount()>0 && list.getChildAdapterPosition(list.getChildAt(0))>10){
list.scrollToPosition(0);
list.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
list.getViewTreeObserver().removeOnPreDrawListener(this);
list.scrollBy(0, V.dp(300));
list.smoothScrollToPosition(0);
return true;
}
});
}else{
list.smoothScrollToPosition(0);
}
smoothScrollRecyclerViewToTop(list);
}
protected int getListWidthForMediaLayout(){

View File

@ -183,6 +183,8 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
public boolean onBackPressed(){
if(currentTab==R.id.tab_profile)
return profileFragment.onBackPressed();
if(currentTab==R.id.tab_search)
return searchFragment.onBackPressed();
return false;
}
}

View File

@ -1,5 +1,31 @@
package org.joinmastodon.android.fragments;
import android.view.ViewTreeObserver;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.V;
public interface ScrollableToTop{
void scrollToTop();
/**
* Utility method to scroll a RecyclerView to top in a way that doesn't suck
* @param list
*/
default void smoothScrollRecyclerViewToTop(RecyclerView list){
if(list.getChildCount()>0 && list.getChildAdapterPosition(list.getChildAt(0))>10){
list.scrollToPosition(0);
list.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
list.getViewTreeObserver().removeOnPreDrawListener(this);
list.scrollBy(0, V.dp(300));
list.smoothScrollToPosition(0);
return true;
}
});
}else{
list.smoothScrollToPosition(0);
}
}
}

View File

@ -16,6 +16,7 @@ import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.accounts.GetFollowSuggestions;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowSuggestion;
import org.joinmastodon.android.model.Relationship;
@ -47,7 +48,7 @@ import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class DiscoverAccountsFragment extends BaseRecyclerFragment<DiscoverAccountsFragment.AccountWrapper>{
public class DiscoverAccountsFragment extends BaseRecyclerFragment<DiscoverAccountsFragment.AccountWrapper> implements ScrollableToTop{
private String accountID;
private Map<String, Relationship> relationships=Collections.emptyMap();
private GetAccountRelationships relationshipsRequest;
@ -120,6 +121,11 @@ public class DiscoverAccountsFragment extends BaseRecyclerFragment<DiscoverAccou
}).exec(accountID);
}
@Override
public void scrollToTop(){
smoothScrollRecyclerViewToTop(list);
}
private class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){

View File

@ -3,11 +3,19 @@ package org.joinmastodon.android.fragments.discover;
import android.app.Fragment;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.ScrollableToTop;
@ -22,21 +30,29 @@ import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.utils.V;
public class DiscoverFragment extends AppKitFragment implements ScrollableToTop{
public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener{
private TabLayout tabLayout;
private ViewPager2 pager;
private FrameLayout[] tabViews;
private TabLayoutMediator tabLayoutMediator;
private EditText searchEdit;
private boolean searchActive;
private FrameLayout searchView;
private ImageButton searchBack, searchClear;
private ProgressBar searchProgress;
private DiscoverPostsFragment postsFragment;
private TrendingHashtagsFragment hashtagsFragment;
private DiscoverNewsFragment newsFragment;
private DiscoverAccountsFragment accountsFragment;
private SearchFragment searchFragment;
private String accountID;
private Runnable searchDebouncer=this::onSearchChangedDebounced;
@Override
public void onCreate(Bundle savedInstanceState){
@ -129,12 +145,68 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop{
});
tabLayoutMediator.attach();
searchEdit=view.findViewById(R.id.search_edit);
searchEdit.setOnFocusChangeListener(this::onSearchEditFocusChanged);
searchEdit.setOnEditorActionListener(this::onSearchEnterPressed);
searchEdit.addTextChangedListener(new TextWatcher(){
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after){
if(s.length()==0){
V.setVisibilityAnimated(searchClear, View.VISIBLE);
}
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count){
searchEdit.removeCallbacks(searchDebouncer);
searchEdit.postDelayed(searchDebouncer, 300);
}
@Override
public void afterTextChanged(Editable s){
if(s.length()==0){
V.setVisibilityAnimated(searchClear, View.INVISIBLE);
}
}
});
searchView=view.findViewById(R.id.search_fragment);
if(searchFragment==null){
searchFragment=new SearchFragment();
Bundle args=new Bundle();
args.putString("account", accountID);
searchFragment.setArguments(args);
searchFragment.setProgressVisibilityListener(this::onSearchProgressVisibilityChanged);
getChildFragmentManager().beginTransaction().add(R.id.search_fragment, searchFragment).commit();
}
searchBack=view.findViewById(R.id.search_back);
searchClear=view.findViewById(R.id.search_clear);
searchProgress=view.findViewById(R.id.search_progress);
searchBack.setEnabled(searchActive);
searchBack.setOnClickListener(v->exitSearch());
if(searchActive){
searchBack.setImageResource(R.drawable.ic_fluent_arrow_left_24_regular);
pager.setVisibility(View.GONE);
tabLayout.setVisibility(View.GONE);
searchView.setVisibility(View.VISIBLE);
}
searchClear.setOnClickListener(v->{
searchEdit.setText("");
searchEdit.removeCallbacks(searchDebouncer);
onSearchChangedDebounced();
});
return view;
}
@Override
public void scrollToTop(){
if(!searchActive){
((ScrollableToTop)getFragmentForPage(pager.getCurrentItem())).scrollToTop();
}else{
searchFragment.scrollToTop();
}
}
public void loadData(){
@ -142,6 +214,35 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop{
postsFragment.loadData();
}
private void onSearchEditFocusChanged(View v, boolean hasFocus){
if(!searchActive && hasFocus){
searchActive=true;
pager.setVisibility(View.GONE);
tabLayout.setVisibility(View.GONE);
searchView.setVisibility(View.VISIBLE);
searchBack.setImageResource(R.drawable.ic_fluent_arrow_left_24_regular);
searchBack.setEnabled(true);
}
}
private void exitSearch(){
searchActive=false;
pager.setVisibility(View.VISIBLE);
tabLayout.setVisibility(View.VISIBLE);
searchView.setVisibility(View.GONE);
searchEdit.clearFocus();
searchEdit.setText("");
searchBack.setImageResource(R.drawable.ic_fluent_search_24_regular);
searchBack.setEnabled(false);
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(searchEdit.getWindowToken(), 0);
}
@Override
protected void onHidden(){
super.onHidden();
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(searchEdit.getWindowToken(), 0);
}
private Fragment getFragmentForPage(int page){
return switch(page){
case 0 -> postsFragment;
@ -152,6 +253,34 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop{
};
}
@Override
public boolean onBackPressed(){
if(searchActive){
exitSearch();
return true;
}
return false;
}
private void onSearchChangedDebounced(){
searchFragment.setQuery(searchEdit.getText().toString());
}
private boolean onSearchEnterPressed(TextView v, int actionId, KeyEvent event){
if(event!=null && event.getAction()!=KeyEvent.ACTION_DOWN)
return true;
searchEdit.removeCallbacks(searchDebouncer);
onSearchChangedDebounced();
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(searchEdit.getWindowToken(), 0);
return true;
}
private void onSearchProgressVisibilityChanged(boolean visible){
V.setVisibilityAnimated(searchProgress, visible ? View.VISIBLE : View.INVISIBLE);
if(searchEdit.length()>0)
V.setVisibilityAnimated(searchClear, visible ? View.INVISIBLE : View.VISIBLE);
}
private class DiscoverPagerAdapter extends RecyclerView.Adapter<SimpleViewHolder>{
@NonNull
@Override

View File

@ -10,6 +10,7 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.trends.GetTrendingLinks;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Card;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.OutlineProviders;
@ -32,7 +33,7 @@ import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class DiscoverNewsFragment extends BaseRecyclerFragment<Card>{
public class DiscoverNewsFragment extends BaseRecyclerFragment<Card> implements ScrollableToTop{
private String accountID;
private List<ImageLoaderRequest> imageRequests=Collections.emptyList();
@ -72,6 +73,11 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment<Card>{
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1, 0, 0));
}
@Override
public void scrollToTop(){
smoothScrollRecyclerViewToTop(list);
}
private class LinksAdapter extends UsableRecyclerView.Adapter<LinkViewHolder> implements ImageLoaderRecyclerAdapter{
public LinksAdapter(){
super(imgLoader);

View File

@ -0,0 +1,291 @@
package org.joinmastodon.android.fragments.discover;
import android.app.Activity;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewTreeObserver;
import android.view.inputmethod.InputMethodManager;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.search.GetSearchResults;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.tabs.TabLayout;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class SearchFragment extends BaseStatusListFragment<SearchResult>{
private String currentQuery;
private List<StatusDisplayItem> prevDisplayItems;
private EnumSet<SearchResult.Type> currentFilter=EnumSet.allOf(SearchResult.Type.class);
private List<SearchResult> unfilteredResults=Collections.emptyList();
private HideableSingleViewRecyclerAdapter headerAdapter;
private ProgressVisibilityListener progressVisibilityListener;
private InputMethodManager imm;
private TabLayout tabLayout;
public SearchFragment(){
setLayout(R.layout.fragment_search);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
loadData();
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
imm=activity.getSystemService(InputMethodManager.class);
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(SearchResult s){
return switch(s.type){
case ACCOUNT -> Collections.singletonList(new AccountStatusDisplayItem(s.id, this, s.account));
case HASHTAG -> Collections.singletonList(new HashtagStatusDisplayItem(s.id, this, s.hashtag));
case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, false, true);
};
}
@Override
protected void addAccountToKnown(SearchResult s){
Account acc=switch(s.type){
case ACCOUNT -> s.account;
case STATUS -> s.status.account;
case HASHTAG -> null;
};
if(acc!=null && !knownAccounts.containsKey(acc.id))
knownAccounts.put(acc.id, acc);
}
@Override
public void onItemClick(String id){
SearchResult res=getResultByID(id);
switch(res.type){
case ACCOUNT -> {
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(res.account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
case HASHTAG -> UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag.name);
case STATUS -> {
Status status=res.status.getContentStatus();
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("status", Parcels.wrap(status));
if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId))
args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId)));
Nav.go(getActivity(), ThreadFragment.class, args);
}
}
if(res.type!=SearchResult.Type.STATUS)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putRecentSearch(res);
}
@Override
protected void doLoadData(int offset, int count){
if(isInRecentMode()){
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().getRecentSearches(sr->{
unfilteredResults=sr;
prevDisplayItems=new ArrayList<>(displayItems);
onDataLoaded(sr, false);
});
}else{
progressVisibilityListener.onProgressVisibilityChanged(true);
currentRequest=new GetSearchResults(currentQuery, null, true)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(SearchResults result){
ArrayList<SearchResult> results=new ArrayList<>();
if(result.accounts!=null){
for(Account acc:result.accounts)
results.add(new SearchResult(acc));
}
if(result.hashtags!=null){
for(Hashtag tag:result.hashtags)
results.add(new SearchResult(tag));
}
if(result.statuses!=null){
for(Status status:result.statuses)
results.add(new SearchResult(status));
}
prevDisplayItems=new ArrayList<>(displayItems);
unfilteredResults=results;
onDataLoaded(filterSearchResults(results), false);
}
})
.exec(accountID);
}
}
@Override
public void updateList(){
if(prevDisplayItems==null){
super.updateList();
return;
}
imgLoader.deactivate();
UiUtils.updateList(prevDisplayItems, displayItems, list, adapter, (i1, i2)->i1.parentID.equals(i2.parentID) && i1.index==i2.index && i1.getType()==i2.getType());
boolean recent=isInRecentMode();
if(recent!=headerAdapter.isVisible())
headerAdapter.setVisible(recent);
imgLoader.activate();
prevDisplayItems=null;
}
@Override
protected void onDataLoaded(List<SearchResult> d, boolean more){
super.onDataLoaded(d, more);
progressVisibilityListener.onProgressVisibilityChanged(false);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
tabLayout=view.findViewById(R.id.tabbar);
tabLayout.setTabTextSize(V.dp(16));
tabLayout.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorTabInactive), UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary));
tabLayout.addTab(tabLayout.newTab().setText(R.string.search_all));
tabLayout.addTab(tabLayout.newTab().setText(R.string.search_people));
tabLayout.addTab(tabLayout.newTab().setText(R.string.hashtags));
tabLayout.addTab(tabLayout.newTab().setText(R.string.posts));
for(int i=0;i<tabLayout.getTabCount();i++){
tabLayout.getTabAt(i).view.textView.setAllCaps(true);
}
tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener(){
@Override
public void onTabSelected(TabLayout.Tab tab){
setFilter(switch(tab.getPosition()){
case 0 -> EnumSet.allOf(SearchResult.Type.class);
case 1 -> EnumSet.of(SearchResult.Type.ACCOUNT);
case 2 -> EnumSet.of(SearchResult.Type.HASHTAG);
case 3 -> EnumSet.of(SearchResult.Type.STATUS);
default -> throw new IllegalStateException("Unexpected value: "+tab.getPosition());
});
}
@Override
public void onTabUnselected(TabLayout.Tab tab){
}
@Override
public void onTabReselected(TabLayout.Tab tab){
}
});
}
@Override
protected RecyclerView.Adapter getAdapter(){
View header=getActivity().getLayoutInflater().inflate(R.layout.item_recent_searches_header, list, false);
header.findViewById(R.id.clear).setOnClickListener(this::onClearRecentClick);
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(headerAdapter=new HideableSingleViewRecyclerAdapter(header));
adapter.addAdapter(super.getAdapter());
headerAdapter.setVisible(isInRecentMode());
return adapter;
}
public void setQuery(String q){
if(Objects.equals(q, currentQuery))
return;
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
currentQuery=q;
refreshing=true;
doLoadData(0, 0);
}
private void setFilter(EnumSet<SearchResult.Type> filter){
if(filter.equals(currentFilter))
return;
currentFilter=filter;
if(isInRecentMode())
return;
// This can be optimized by not rebuilding display items every time filter is changed, but I'm too lazy
prevDisplayItems=new ArrayList<>(displayItems);
refreshing=true;
onDataLoaded(filterSearchResults(unfilteredResults), false);
}
private List<SearchResult> filterSearchResults(List<SearchResult> input){
if(currentFilter.size()==SearchResult.Type.values().length)
return input;
return input.stream().filter(sr->currentFilter.contains(sr.type)).collect(Collectors.toList());
}
protected SearchResult getResultByID(String id){
for(SearchResult s:data){
if(s.id.equals(id)){
return s;
}
}
return null;
}
private void onClearRecentClick(View v){
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().clearRecentSearches();
if(isInRecentMode()){
prevDisplayItems=new ArrayList<>(displayItems);
refreshing=true;
onDataLoaded(unfilteredResults=Collections.emptyList(), false);
}
}
private boolean isInRecentMode(){
return TextUtils.isEmpty(currentQuery);
}
public void setProgressVisibilityListener(ProgressVisibilityListener progressVisibilityListener){
this.progressVisibilityListener=progressVisibilityListener;
}
@Override
public void onScrollStarted(){
super.onScrollStarted();
if(imm.isActive()){
imm.hideSoftInputFromWindow(getActivity().getWindow().getDecorView().getWindowToken(), 0);
}
}
@FunctionalInterface
public interface ProgressVisibilityListener{
void onProgressVisibilityChanged(boolean visible);
}
}

View File

@ -9,6 +9,7 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.trends.GetTrendingHashtags;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
@ -24,7 +25,7 @@ import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag>{
public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> implements ScrollableToTop{
private String accountID;
public TrendingHashtagsFragment(){
@ -60,6 +61,11 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag>{
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, .5f, 16, 16));
}
@Override
public void scrollToTop(){
smoothScrollRecyclerViewToTop(list);
}
private class HashtagsAdapter extends RecyclerView.Adapter<HashtagViewHolder>{
@NonNull
@Override

View File

@ -0,0 +1,64 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
public class SearchResult extends BaseModel implements DisplayItemsParent{
public Account account;
public Hashtag hashtag;
public Status status;
@RequiredField
public Type type;
public transient String id;
public SearchResult(){}
public SearchResult(Account acc){
account=acc;
type=Type.ACCOUNT;
generateID();
}
public SearchResult(Hashtag tag){
hashtag=tag;
type=Type.HASHTAG;
generateID();
}
public SearchResult(Status status){
this.status=status;
type=Type.STATUS;
generateID();
}
public String getID(){
return id;
}
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
if(account!=null)
account.postprocess();
if(hashtag!=null)
hashtag.postprocess();
if(status!=null)
status.postprocess();
generateID();
}
private void generateID(){
id=switch(type){
case ACCOUNT -> "acc_"+account.id;
case HASHTAG -> "tag_"+hashtag.name.hashCode();
case STATUS -> "post_"+status.id;
};
}
public enum Type{
ACCOUNT,
HASHTAG,
STATUS
}
}

View File

@ -17,7 +17,6 @@ import org.joinmastodon.android.api.requests.search.GetSearchResults;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.ui.drawables.ComposeAutocompleteBackgroundDrawable;
@ -27,13 +26,10 @@ import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.Collections;
import java.util.List;
import java.util.function.BiPredicate;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.APIRequest;
@ -165,7 +161,7 @@ public class ComposeAutocompleteViewController{
.filter(e->e.visibleInPicker && e.shortcode.startsWith(_text))
.map(WrappedEmoji::new)
.collect(Collectors.toList());
updateList(oldList, emojis, emojisAdapter, (e1, e2)->e1.emoji.shortcode.equals(e2.emoji.shortcode));
UiUtils.updateList(oldList, emojis, list, emojisAdapter, (e1, e2)->e1.emoji.shortcode.equals(e2.emoji.shortcode));
}
}
@ -181,39 +177,15 @@ public class ComposeAutocompleteViewController{
return contentView;
}
private <T> void updateList(List<T> oldList, List<T> newList, RecyclerView.Adapter<?> adapter, BiPredicate<T, T> areItemsSame){
DiffUtil.calculateDiff(new DiffUtil.Callback(){
@Override
public int getOldListSize(){
return oldList.size();
}
@Override
public int getNewListSize(){
return newList.size();
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){
return areItemsSame.test(oldList.get(oldItemPosition), newList.get(newItemPosition));
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){
return true;
}
}).dispatchUpdatesTo(adapter);
}
private void doSearchUsers(){
currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.ACCOUNTS)
currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.ACCOUNTS, false)
.setCallback(new Callback<>(){
@Override
public void onSuccess(SearchResults result){
currentRequest=null;
List<WrappedAccount> oldList=users;
users=result.accounts.stream().map(WrappedAccount::new).collect(Collectors.toList());
updateList(oldList, users, usersAdapter, (a1, a2)->a1.account.id.equals(a2.account.id));
UiUtils.updateList(oldList, users, list, usersAdapter, (a1, a2)->a1.account.id.equals(a2.account.id));
if(listIsHidden){
listIsHidden=false;
V.setVisibilityAnimated(list, View.VISIBLE);
@ -230,14 +202,14 @@ public class ComposeAutocompleteViewController{
}
private void doSearchHashtags(){
currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.HASHTAGS)
currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.HASHTAGS, false)
.setCallback(new Callback<>(){
@Override
public void onSuccess(SearchResults result){
currentRequest=null;
List<Hashtag> oldList=hashtags;
hashtags=result.hashtags;
updateList(oldList, hashtags, hashtagsAdapter, (t1, t2)->t1.name.equals(t2.name));
UiUtils.updateList(oldList, hashtags, list, hashtagsAdapter, (t1, t2)->t1.name.equals(t2.name));
if(listIsHidden){
listIsHidden=false;
V.setVisibilityAnimated(list, View.VISIBLE);

View File

@ -0,0 +1,92 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class AccountStatusDisplayItem extends StatusDisplayItem{
public final Account account;
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
private CharSequence parsedName;
public ImageLoaderRequest avaRequest;
public AccountStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Account account){
super(parentID, parentFragment);
this.account=account;
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
emojiHelper.setText(parsedName);
if(!TextUtils.isEmpty(account.avatar))
avaRequest=new UrlImageLoaderRequest(account.avatar, V.dp(50), V.dp(50));
}
@Override
public Type getType(){
return Type.ACCOUNT;
}
@Override
public int getImageCount(){
return 1+emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int index){
if(index==0)
return avaRequest;
return emojiHelper.getImageRequest(index-1);
}
public static class Holder extends StatusDisplayItem.Holder<AccountStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView name, username;
private final ImageView photo;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_account, parent);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
photo=findViewById(R.id.photo);
photo.setOutlineProvider(OutlineProviders.roundedRect(12));
photo.setClipToOutline(true);
}
@Override
public void onBind(AccountStatusDisplayItem item){
name.setText(item.parsedName);
username.setText("@"+item.account.acct);
}
@Override
public void setImage(int index, Drawable image){
if(image instanceof Animatable && !((Animatable) image).isRunning())
((Animatable) image).start();
if(index==0){
photo.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-1, image);
name.invalidate();
}
}
@Override
public void clearImage(int index){
setImage(index, null);
}
}
}

View File

@ -0,0 +1,48 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.view.ViewGroup;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.ui.views.HashtagChartView;
public class HashtagStatusDisplayItem extends StatusDisplayItem{
public final Hashtag tag;
public HashtagStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Hashtag tag){
super(parentID, parentFragment);
this.tag=tag;
}
@Override
public Type getType(){
return Type.HASHTAG;
}
public static class Holder extends StatusDisplayItem.Holder<HashtagStatusDisplayItem>{
private final TextView title, subtitle;
private final HashtagChartView chart;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.item_trending_hashtag, parent);
title=findViewById(R.id.title);
subtitle=findViewById(R.id.subtitle);
chart=findViewById(R.id.chart);
}
@Override
public void onBind(HashtagStatusDisplayItem _item){
Hashtag item=_item.tag;
title.setText('#'+item.name);
int numPeople=item.history.get(0).accounts;
if(item.history.size()>1)
numPeople+=item.history.get(1).accounts;
subtitle.setText(_item.parentFragment.getResources().getQuantityString(R.plurals.x_people_talking, numPeople, numPeople));
chart.setData(item.history);
}
}
}

View File

@ -30,6 +30,7 @@ public abstract class StatusDisplayItem{
public final String parentID;
public final BaseStatusListFragment parentFragment;
public boolean inset;
public int index;
public StatusDisplayItem(String parentID, BaseStatusListFragment parentFragment){
this.parentID=parentID;
@ -60,6 +61,8 @@ public abstract class StatusDisplayItem{
case CARD -> new LinkCardStatusDisplayItem.Holder(activity, parent);
case FOOTER -> new FooterStatusDisplayItem.Holder(activity, parent);
case ACCOUNT_CARD -> new AccountCardStatusDisplayItem.Holder(activity, parent);
case ACCOUNT -> new AccountStatusDisplayItem.Holder(activity, parent);
case HASHTAG -> new HashtagStatusDisplayItem.Holder(activity, parent);
};
}
@ -110,8 +113,11 @@ public abstract class StatusDisplayItem{
if(addFooter){
items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID));
}
for(StatusDisplayItem item:items)
int i=1;
for(StatusDisplayItem item:items){
item.inset=inset;
item.index=i++;
}
return items;
}
@ -135,6 +141,8 @@ public abstract class StatusDisplayItem{
CARD,
FOOTER,
ACCOUNT_CARD,
ACCOUNT,
HASHTAG
}
public static abstract class Holder<T extends StatusDisplayItem> extends BindableViewHolder<T> implements UsableRecyclerView.Clickable{

View File

@ -34,7 +34,7 @@ public class CustomEmojiHelper{
}
public ImageLoaderRequest getImageRequest(int image){
return requests.get(image);
return image<requests.size() ? requests.get(image) : null; // TODO fix this in the image loader
}
public void setImageDrawable(int image, Drawable drawable){

View File

@ -0,0 +1,32 @@
package org.joinmastodon.android.ui.utils;
import android.view.View;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
public class HideableSingleViewRecyclerAdapter extends SingleViewRecyclerAdapter{
private boolean visible=true;
public HideableSingleViewRecyclerAdapter(View view){
super(view);
}
@Override
public int getItemCount(){
return visible ? 1 : 0;
}
public void setVisible(boolean visible){
if(visible==this.visible)
return;
this.visible=visible;
if(visible)
notifyItemInserted(0);
else
notifyItemRemoved(0);
}
public boolean isVisible(){
return visible;
}
}

View File

@ -39,12 +39,15 @@ import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.BiPredicate;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import androidx.annotation.AttrRes;
import androidx.annotation.StringRes;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
@ -311,4 +314,39 @@ public class UiUtils{
.exec(accountID);
}
}
public static <T> void updateList(List<T> oldList, List<T> newList, RecyclerView list, RecyclerView.Adapter<?> adapter, BiPredicate<T, T> areItemsSame){
// Save topmost item position and offset because for some reason RecyclerView would scroll the list to weird places when you insert items at the top
int topItem, topItemOffset;
if(list.getChildCount()==0){
topItem=topItemOffset=0;
}else{
View child=list.getChildAt(0);
topItem=list.getChildAdapterPosition(child);
topItemOffset=child.getTop();
}
DiffUtil.calculateDiff(new DiffUtil.Callback(){
@Override
public int getOldListSize(){
return oldList.size();
}
@Override
public int getNewListSize(){
return newList.size();
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){
return areItemsSame.test(oldList.get(oldItemPosition), newList.get(newItemPosition));
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){
return true;
}
}).dispatchUpdatesTo(adapter);
list.scrollToPosition(topItem);
list.scrollBy(0, topItemOffset);
}
}

View File

@ -38,7 +38,7 @@ public class HashtagChartView extends View{
}
public void setData(List<History> data){
int max=0;
int max=1; // avoid dividing by zero
for(History h:data){
max=Math.max(h.accounts, max);
}

View File

@ -2,8 +2,8 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape>
<solid android:color="@color/gray_200"/>
<stroke android:color="@color/gray_400" android:width="1dp"/>
<solid android:color="?attr/colorSearchField"/>
<stroke android:color="?attr/colorTabInactive" android:width="1dp"/>
<corners android:radius="4dp"/>
<padding android:left="8dp" android:top="8dp" android:right="8dp" android:bottom="8dp"/>
</shape>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2zm3.53 6.47l-0.084-0.073c-0.26-0.194-0.619-0.196-0.882-0.007L14.47 8.47 12 10.939l-2.47-2.47-0.084-0.072C9.186 8.203 8.827 8.201 8.564 8.39L8.47 8.47 8.397 8.554C8.203 8.814 8.201 9.173 8.39 9.436L8.47 9.53 10.939 12l-2.47 2.47-0.072 0.084c-0.194 0.26-0.196 0.619-0.007 0.882l0.08 0.094 0.084 0.073c0.26 0.194 0.619 0.196 0.882 0.007l0.094-0.08L12 13.061l2.47 2.47 0.084 0.072c0.26 0.194 0.619 0.196 0.882 0.007l0.094-0.08 0.073-0.084c0.194-0.26 0.196-0.619 0.007-0.882l-0.08-0.094L13.061 12l2.47-2.47 0.072-0.084c0.194-0.26 0.196-0.619 0.007-0.882L15.53 8.47l-0.084-0.073 0.084 0.073z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="12dp"
android:paddingLeft="16dp"
android:paddingRight="16dp">
<ImageView
android:id="@+id/photo"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
tools:src="#0f0"/>
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="24dp"
android:layout_toEndOf="@id/photo"
android:textAppearance="@style/m3_title_medium"
android:fontFamily="sans-serif"
android:singleLine="true"
android:ellipsize="end"
tools:text="User Name"/>
<TextView
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_toEndOf="@id/photo"
android:layout_alignBottom="@id/photo"
android:layout_marginBottom="-4dp"
android:textAppearance="@style/m3_body_medium"
android:textColor="?android:textColorSecondary"
android:singleLine="true"
android:ellipsize="end"
tools:text="\@user@domain"/>
</RelativeLayout>

View File

@ -15,29 +15,65 @@
android:paddingBottom="16dp"
android:background="?android:statusBarColor">
<EditText
android:id="@+id/search_edit"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="40dp"
android:hint="@string/search_hint"
android:textColorHint="?colorSearchHint"
android:textColor="?android:textColorPrimary"
android:textSize="16dp"
android:singleLine="true"
android:inputType="textFilter"
android:paddingLeft="48dp"
android:paddingRight="48dp"
android:elevation="0dp"
android:background="@drawable/bg_search_field"/>
android:layout_height="wrap_content"
android:background="@drawable/bg_search_field"
android:outlineProvider="background"
android:clipToOutline="true">
<ImageButton
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="start"
android:layout_marginStart="4dp"
android:background="?android:selectableItemBackgroundBorderless"
android:tint="?colorSearchHint"
android:src="@drawable/ic_fluent_search_24_regular"/>
<EditText
android:id="@+id/search_edit"
android:layout_width="match_parent"
android:layout_height="40dp"
android:hint="@string/search_hint"
android:textColorHint="?colorSearchHint"
android:textColor="?android:textColorPrimary"
android:textSize="16dp"
android:singleLine="true"
android:inputType="textFilter"
android:imeOptions="actionSearch"
android:paddingLeft="48dp"
android:paddingRight="48dp"
android:paddingTop="0dp"
android:paddingBottom="0dp"
android:background="@null"
android:elevation="0dp"/>
<ImageButton
android:id="@+id/search_back"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="start"
android:layout_marginStart="4dp"
android:background="?android:selectableItemBackgroundBorderless"
android:tint="?colorSearchHint"
android:elevation="1dp"
android:src="@drawable/ic_fluent_search_24_regular"/>
<ImageButton
android:id="@+id/search_clear"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="end"
android:layout_marginEnd="4dp"
android:background="?android:selectableItemBackgroundBorderless"
android:tint="?colorSearchHint"
android:elevation="1dp"
android:visibility="invisible"
android:src="@drawable/ic_fluent_dismiss_24_regular"/>
<ProgressBar
android:id="@+id/search_progress"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="14dp"
android:indeterminateTint="?colorSearchHint"
style="?android:progressBarStyleSmall"
android:visibility="invisible"/>
</FrameLayout>
</FrameLayout>
@ -59,4 +95,11 @@
android:layout_height="0dp"
android:layout_weight="1"/>
<FrameLayout
android:id="@+id/search_fragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="gone"/>
</me.grishka.appkit.views.FragmentRootLinearLayout>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<me.grishka.appkit.views.FragmentRootLinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:id="@+id/appkit_loader_root"
xmlns:android="http://schemas.android.com/apk/res/android"
android:background="?android:windowBackground">
<org.joinmastodon.android.ui.tabs.TabLayout
android:id="@+id/tabbar"
android:layout_width="match_parent"
android:layout_height="48dp"
app:tabGravity="start"
app:tabMinWidth="120dp"
app:tabIndicator="@drawable/mtrl_tabs_default_indicator"
app:tabIndicatorAnimationMode="elastic"
app:tabIndicatorColor="?android:textColorPrimary"
app:tabMode="scrollable"
android:background="@drawable/bg_discover_tabs"/>
<FrameLayout
android:id="@+id/appkit_loader_content"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/loading"
android:id="@+id/loading"/>
<ViewStub android:layout="?errorViewLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/error"
android:visibility="gone"/>
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/content_stub"/>
</FrameLayout>
</me.grishka.appkit.views.FragmentRootLinearLayout>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="0dp"
android:layout_height="28dp"
android:layout_weight="1"
android:layout_marginTop="12dp"
android:layout_marginStart="16dp"
android:layout_marginBottom="8dp"
android:textAppearance="@style/m3_title_large"
android:singleLine="true"
android:ellipsize="end"
android:text="@string/recent_searches"/>
<ImageButton
android:id="@+id/clear"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="6dp"
android:tint="?android:textColorSecondary"
android:background="?android:selectableItemBackgroundBorderless"
android:src="@drawable/ic_fluent_dismiss_circle_24_filled"/>
</LinearLayout>

View File

@ -218,4 +218,7 @@
<string name="visibility_public">Public</string>
<string name="visibility_followers_only">Followers only</string>
<string name="visibility_private">Only people I mention</string>
<string name="search_all">All</string>
<string name="search_people">People</string>
<string name="recent_searches">Recent searches</string>
</resources>

View File

@ -75,15 +75,15 @@
<item name="colorPollMostVoted">@color/primary_700</item>
<item name="colorPollVoted">@color/gray_600</item>
<item name="colorAccentLight">@color/primary_600</item>
<item name="colorAccentLightest">@color/primary_800</item>
<item name="colorTabInactive">@color/gray_400</item>
<!-- TODO dark colors -->
<item name="colorSearchField">@color/gray_200</item>
<item name="colorSearchHint">@color/gray_600</item>
<item name="colorTabInactive">@color/gray_400</item>
<item name="colorAccentLightest">@color/primary_100</item>
<item name="colorSearchField">@color/gray_700</item>
<item name="colorSearchHint">@color/gray_300</item>
<item name="buttonBackground">@drawable/bg_button_primary_light_on_dark</item>
<item name="android:editTextBackground">@drawable/bg_edittext_light</item>
<item name="android:editTextBackground">@drawable/bg_edittext_dark</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar" tools:ignore="NewApi">false</item>