From 5cfe6f8fa55f5e673d326b77f6f250c4d0a78ce3 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Mon, 30 Apr 2018 11:30:10 +0200 Subject: [PATCH] make search find statuses (#613) --- .../keylesspalace/tusky/SearchActivity.java | 115 ++------- .../tusky/adapter/SearchResultsAdapter.java | 119 ++++++--- .../tusky/di/FragmentBuildersModule.kt | 3 + .../tusky/fragment/SearchFragment.kt | 231 ++++++++++++++++++ app/src/main/res/layout/activity_search.xml | 36 +-- app/src/main/res/layout/fragment_search.xml | 31 +++ 6 files changed, 380 insertions(+), 155 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt create mode 100644 app/src/main/res/layout/fragment_search.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java b/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java index c45b80da..430c8c5d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java @@ -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 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 callback = new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response 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 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); + @Override + public AndroidInjector supportFragmentInjector() { + return fragmentInjector; } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java index 7282330d..24aea0be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java @@ -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 accountList; + private List statusList; + private List concreteStatusList; private List 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()) { - 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); + 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); + } } - } @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()) { - return VIEW_TYPE_HASHTAG; + 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)); } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index dc01cc2a..93adf2d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -40,4 +40,7 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun notificationsFragment(): NotificationsFragment + + @ContributesAndroidInjector + abstract fun searchFragment(): SearchFragment } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt new file mode 100644 index 00000000..8ba65df8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -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 . */ + +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 { + override fun onResponse(call: Call, response: Response) { + 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, 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 { + override fun onResponse(call: Call?, response: Response?) { + status.reblogged = true + searchAdapter.updateStatusAtPosition(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia), position) + } + + override fun onFailure(call: Call?, 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 { + override fun onResponse(call: Call?, response: Response?) { + status.favourited = true + searchAdapter.updateStatusAtPosition(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia), position) + } + + override fun onFailure(call: Call?, 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?, 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) + } + +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml index 4573b670..9c6671a0 100644 --- a/app/src/main/res/layout/activity_search.xml +++ b/app/src/main/res/layout/activity_search.xml @@ -1,16 +1,16 @@ + app:contentInsetStartWithNavigation="0dp" + app:navigationIcon="?attr/homeAsUpIndicator" /> - - - - - - - - - + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml new file mode 100644 index 00000000..724a69cd --- /dev/null +++ b/app/src/main/res/layout/fragment_search.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + +