make search find statuses (#613)

This commit is contained in:
Konrad Pozniak 2018-04-30 11:30:10 +02:00 committed by GitHub
parent c72619b838
commit 5cfe6f8fa5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 380 additions and 155 deletions

View file

@ -20,53 +20,38 @@ import android.app.SearchableInfo;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentTransaction;
import android.support.v7.app.ActionBar;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.SearchView;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.keylesspalace.tusky.adapter.SearchResultsAdapter;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.SearchResults;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.fragment.SearchFragment;
import javax.inject.Inject;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.support.HasSupportFragmentInjector;
public class SearchActivity extends BaseActivity implements SearchView.OnQueryTextListener,
LinkListener, Injectable {
private static final String TAG = "SearchActivity"; // logging tag
HasSupportFragmentInjector {
@Inject
public MastodonApi mastodonApi;
public DispatchingAndroidInjector<Fragment> fragmentInjector;
private ProgressBar progressBar;
private TextView messageNoResults;
private SearchResultsAdapter adapter;
private String currentQuery;
private SearchFragment searchFragment;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_search);
progressBar = findViewById(R.id.progress_bar);
messageNoResults = findViewById(R.id.message_no_results);
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar bar = getSupportActionBar();
@ -76,11 +61,11 @@ public class SearchActivity extends BaseActivity implements SearchView.OnQueryTe
bar.setDisplayShowTitleEnabled(false);
}
RecyclerView recyclerView = findViewById(R.id.recycler_view);
recyclerView.setHasFixedSize(true);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
adapter = new SearchResultsAdapter(this);
recyclerView.setAdapter(adapter);
searchFragment = new SearchFragment();
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
fragmentTransaction.replace(R.id.fragment_container, searchFragment);
fragmentTransaction.commit();
handleIntent(getIntent());
}
@ -128,29 +113,10 @@ public class SearchActivity extends BaseActivity implements SearchView.OnQueryTe
return false;
}
@Override
public void onViewAccount(String id) {
Intent intent = new Intent(this, AccountActivity.class);
intent.putExtra("id", id);
startActivity(intent);
}
@Override
public void onViewTag(String tag) {
Intent intent = new Intent(this, ViewTagActivity.class);
intent.putExtra("hashtag", tag);
startActivity(intent);
}
@Override
public void onViewURL(String url) {
LinkHelper.openLink(url, getApplicationContext());
}
private void handleIntent(Intent intent) {
if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
currentQuery = intent.getStringExtra(SearchManager.QUERY);
search(currentQuery);
searchFragment.search(currentQuery);
}
}
@ -169,51 +135,8 @@ public class SearchActivity extends BaseActivity implements SearchView.OnQueryTe
searchView.setMaxWidth(Integer.MAX_VALUE);
}
private void search(String query) {
clearResults();
Callback<SearchResults> callback = new Callback<SearchResults>() {
@Override
public void onResponse(@NonNull Call<SearchResults> call, @NonNull Response<SearchResults> response) {
if (response.isSuccessful()) {
SearchResults results = response.body();
if (results != null && (results.getAccounts().size() > 0 || results.getHashtags().size() > 0)) {
adapter.updateSearchResults(results);
hideFeedback();
} else {
displayNoResults();
}
} else {
onSearchFailure();
}
}
@Override
public void onFailure(@NonNull Call<SearchResults> call, @NonNull Throwable t) {
onSearchFailure();
}
};
mastodonApi.search(query, false)
.enqueue(callback);
}
private void onSearchFailure() {
displayNoResults();
Log.e(TAG, "Search request failed.");
}
private void clearResults() {
adapter.updateSearchResults(null);
progressBar.setVisibility(View.VISIBLE);
messageNoResults.setVisibility(View.GONE);
}
private void displayNoResults() {
progressBar.setVisibility(View.GONE);
messageNoResults.setVisibility(View.VISIBLE);
}
private void hideFeedback() {
progressBar.setVisibility(View.GONE);
messageNoResults.setVisibility(View.GONE);
public AndroidInjector<Fragment> supportFragmentInjector() {
return fragmentInjector;
}
}

View file

@ -15,6 +15,8 @@
package com.keylesspalace.tusky.adapter;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
@ -24,28 +26,51 @@ import android.widget.TextView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.SearchResults;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.ViewDataUtils;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class SearchResultsAdapter extends RecyclerView.Adapter {
private static final int VIEW_TYPE_ACCOUNT = 0;
private static final int VIEW_TYPE_HASHTAG = 1;
private static final int VIEW_TYPE_STATUS = 1;
private static final int VIEW_TYPE_HASHTAG = 2;
private List<Account> accountList;
private List<Status> statusList;
private List<StatusViewData.Concrete> concreteStatusList;
private List<String> hashtagList;
private LinkListener linkListener;
public SearchResultsAdapter(LinkListener listener) {
super();
accountList = new ArrayList<>();
hashtagList = new ArrayList<>();
linkListener = listener;
private boolean mediaPreviewsEnabled;
private boolean alwaysShowSensitiveMedia;
private LinkListener linkListener;
private StatusActionListener statusListener;
public SearchResultsAdapter(boolean mediaPreviewsEnabled, boolean alwaysShowSensitiveMedia,
LinkListener linkListener, StatusActionListener statusListener) {
this.accountList = Collections.emptyList();
this.statusList = Collections.emptyList();
this.concreteStatusList = new ArrayList<>();
this.hashtagList = Collections.emptyList();
this.mediaPreviewsEnabled = mediaPreviewsEnabled;
this.alwaysShowSensitiveMedia = alwaysShowSensitiveMedia;
this.linkListener = linkListener;
this.statusListener = statusListener;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
default:
case VIEW_TYPE_ACCOUNT: {
@ -58,44 +83,83 @@ public class SearchResultsAdapter extends RecyclerView.Adapter {
.inflate(R.layout.item_hashtag, parent, false);
return new HashtagViewHolder(view);
}
case VIEW_TYPE_STATUS: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_status, parent, false);
return new StatusViewHolder(view);
}
}
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
if (position < accountList.size()) {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (position >= accountList.size()) {
if(position >= accountList.size() + concreteStatusList.size()) {
HashtagViewHolder holder = (HashtagViewHolder) viewHolder;
int index = position - accountList.size() - statusList.size();
holder.setup(hashtagList.get(index), linkListener);
} else {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
int index = position - accountList.size();
holder.setupWithStatus(concreteStatusList.get(index), statusListener, mediaPreviewsEnabled);
}
} else {
AccountViewHolder holder = (AccountViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position));
holder.setupLinkListener(linkListener);
} else {
HashtagViewHolder holder = (HashtagViewHolder) viewHolder;
int index = position - accountList.size();
holder.setup(hashtagList.get(index), linkListener);
}
}
@Override
public int getItemCount() {
return accountList.size() + hashtagList.size();
return accountList.size() + hashtagList.size() + concreteStatusList.size();
}
@Override
public int getItemViewType(int position) {
if (position >= accountList.size()) {
if(position >= accountList.size() + concreteStatusList.size()) {
return VIEW_TYPE_HASHTAG;
} else {
return VIEW_TYPE_STATUS;
}
} else {
return VIEW_TYPE_ACCOUNT;
}
}
public @Nullable Status getStatusAtPosition(int position) {
return statusList.get(position - accountList.size());
}
public @Nullable StatusViewData.Concrete getConcreteStatusAtPosition(int position) {
return concreteStatusList.get(position - accountList.size());
}
public void updateStatusAtPosition(StatusViewData.Concrete status, int position) {
concreteStatusList.set(position - accountList.size(), status);
}
public void removeStatusAtPosition(int position) {
concreteStatusList.remove(position - accountList.size());
notifyItemRemoved(position);
}
public void updateSearchResults(SearchResults results) {
if (results != null) {
accountList.addAll(results.getAccounts());
hashtagList.addAll(results.getHashtags());
accountList = results.getAccounts();
statusList = results.getStatuses();
for(Status status: results.getStatuses()) {
concreteStatusList.add(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia));
}
hashtagList = results.getHashtags();
} else {
accountList.clear();
hashtagList.clear();
accountList = Collections.emptyList();
statusList = Collections.emptyList();
concreteStatusList.clear();
hashtagList = Collections.emptyList();
}
notifyDataSetChanged();
}
@ -110,12 +174,7 @@ public class SearchResultsAdapter extends RecyclerView.Adapter {
void setup(final String tag, final LinkListener listener) {
hashtag.setText(String.format("#%s", tag));
hashtag.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onViewTag(tag);
}
});
hashtag.setOnClickListener(v -> listener.onViewTag(tag));
}
}
}

View file

@ -40,4 +40,7 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector
abstract fun notificationsFragment(): NotificationsFragment
@ContributesAndroidInjector
abstract fun searchFragment(): SearchFragment
}

View file

@ -0,0 +1,231 @@
/* Copyright 2018 Conny Duck
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment
import android.content.Intent
import android.os.Bundle
import android.preference.PreferenceManager
import android.support.v7.widget.DividerItemDecoration
import android.support.v7.widget.LinearLayoutManager
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.adapter.SearchResultsAdapter
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.SearchResults
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.ViewDataUtils
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.android.synthetic.main.fragment_search.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import javax.inject.Inject
class SearchFragment : SFragment(), StatusActionListener, Injectable {
@Inject
lateinit var timelineCases: TimelineCases
private lateinit var searchAdapter: SearchResultsAdapter
private var alwaysShowSensitiveMedia = false
private var mediaPreviewEnabled = true
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_search, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false)
mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true)
searchRecyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
searchRecyclerView.layoutManager = LinearLayoutManager(view.context)
searchAdapter = SearchResultsAdapter(mediaPreviewEnabled, alwaysShowSensitiveMedia, this, this)
searchRecyclerView.adapter = searchAdapter
}
fun search(query: String) {
clearResults()
val callback = object : Callback<SearchResults> {
override fun onResponse(call: Call<SearchResults>, response: Response<SearchResults>) {
if (response.isSuccessful) {
val results = response.body()
if (results != null && (results.accounts.isNotEmpty() || results.statuses.isNotEmpty() || results.hashtags.isNotEmpty())) {
searchAdapter.updateSearchResults(results)
hideFeedback()
} else {
displayNoResults()
}
} else {
onSearchFailure()
}
}
override fun onFailure(call: Call<SearchResults>, t: Throwable) {
onSearchFailure()
}
}
mastodonApi.search(query, true)
.enqueue(callback)
}
private fun onSearchFailure() {
displayNoResults()
Log.e(TAG, "Search request failed.")
}
private fun clearResults() {
searchAdapter.updateSearchResults(null)
searchProgressBar.visibility = View.VISIBLE
searchNoResultsText.visibility = View.GONE
}
private fun displayNoResults() {
searchProgressBar.visibility = View.GONE
searchNoResultsText.visibility = View.VISIBLE
}
private fun hideFeedback() {
searchProgressBar.visibility = View.GONE
searchNoResultsText.visibility = View.GONE
}
override fun timelineCases(): TimelineCases {
return timelineCases
}
override fun removeItem(position: Int) {
searchAdapter.removeStatusAtPosition(position)
}
override fun removeAllByAccountId(accountId: String?) {
// not supported
}
override fun onReply(position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if(status != null) {
super.reply(status)
}
}
override fun onReblog(reblog: Boolean, position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if(status != null) {
timelineCases.reblogWithCallback(status, reblog, object: Callback<Status> {
override fun onResponse(call: Call<Status>?, response: Response<Status>?) {
status.reblogged = true
searchAdapter.updateStatusAtPosition(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia), position)
}
override fun onFailure(call: Call<Status>?, t: Throwable?) {
Log.d(TAG, "Failed to reblog status " + status.id, t)
}
})
}
}
override fun onFavourite(favourite: Boolean, position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if(status != null) {
timelineCases.favouriteWithCallback(status, favourite, object: Callback<Status> {
override fun onResponse(call: Call<Status>?, response: Response<Status>?) {
status.favourited = true
searchAdapter.updateStatusAtPosition(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia), position)
}
override fun onFailure(call: Call<Status>?, t: Throwable?) {
Log.d(TAG, "Failed to favourite status " + status.id, t)
}
})
}
}
override fun onMore(view: View?, position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if(status != null) {
more(status, view, position)
}
}
override fun onViewMedia(urls: Array<out String>?, index: Int, type: Attachment.Type?, view: View?) {
viewMedia(urls, index, type, view)
}
override fun onViewThread(position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if(status != null) {
viewThread(status)
}
}
override fun onOpenReblog(position: Int) {
// there are no reblogs in search results
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
val status = searchAdapter.getConcreteStatusAtPosition(position)
if(status != null) {
val newStatus = StatusViewData.Builder(status)
.setIsExpanded(expanded).createStatusViewData()
searchAdapter.updateStatusAtPosition(newStatus, position)
}
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
val status = searchAdapter.getConcreteStatusAtPosition(position)
if(status != null) {
val newStatus = StatusViewData.Builder(status)
.setIsShowingSensitiveContent(isShowing).createStatusViewData()
searchAdapter.updateStatusAtPosition(newStatus, position)
}
}
override fun onLoadMore(position: Int) {
// not needed here, search is not paginated
}
companion object {
const val TAG = "SearchFragment"
}
override fun onViewAccount(id: String) {
val intent = Intent(context, AccountActivity::class.java)
intent.putExtra("id", id)
startActivity(intent)
}
override fun onViewTag(tag: String) {
val intent = Intent(context, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
startActivity(intent)
}
}

View file

@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context="com.keylesspalace.tusky.SearchActivity">
<android.support.design.widget.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stateListAnimator="@null"
android:elevation="0dp"
android:stateListAnimator="@null"
app:layout_collapseMode="pin">
<android.support.v7.widget.Toolbar
@ -18,38 +18,16 @@
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/toolbar_background_color"
app:navigationIcon="?attr/homeAsUpIndicator"
app:contentInsetStartWithNavigation="0dp" />
app:contentInsetStartWithNavigation="0dp"
app:navigationIcon="?attr/homeAsUpIndicator" />
</android.support.design.widget.AppBarLayout>
<RelativeLayout
<FrameLayout
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<android.support.v7.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/recycler_view" />
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/progress_bar"
android:layout_centerInParent="true"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/message_no_results"
android:text="@string/search_no_results"
android:layout_centerInParent="true"
android:visibility="gone" />
</RelativeLayout>
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<include layout="@layout/toolbar_shadow_shim" />

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/searchRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="@layout/item_account" />
<ProgressBar
android:id="@+id/searchProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/searchNoResultsText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/search_no_results"
android:visibility="gone" />
<include layout="@layout/item_status_bottom_sheet" />
</android.support.design.widget.CoordinatorLayout>