Search now actually searches, yay
This commit is contained in:
parent
fa9112e117
commit
c60bc253e5
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(){
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(){
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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{
|
||||
|
|
|
@ -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){
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue