package com.boardgamegeek.ui; import android.app.Activity; import android.app.SearchManager; import android.content.Context; import android.content.Intent; import android.graphics.Typeface; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.PluralsRes; import android.support.design.widget.CoordinatorLayout; import android.support.design.widget.Snackbar; import android.support.v4.app.Fragment; import android.support.v4.app.LoaderManager.LoaderCallbacks; import android.support.v4.content.ContextCompat; import android.support.v4.content.Loader; import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.text.TextUtils; import android.util.Pair; import android.util.SparseBooleanArray; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnLongClickListener; import android.view.ViewGroup; import android.widget.TextView; import android.widget.Toast; import com.boardgamegeek.R; import com.boardgamegeek.io.Adapter; import com.boardgamegeek.io.BggService; import com.boardgamegeek.model.SearchResponse; import com.boardgamegeek.model.SearchResult; import com.boardgamegeek.ui.SearchResultsFragment.SearchData; import com.boardgamegeek.ui.loader.BggLoader; import com.boardgamegeek.ui.loader.SafeResponse; import com.boardgamegeek.ui.widget.SafeViewTarget; import com.boardgamegeek.util.ActivityUtils; import com.boardgamegeek.util.AnimationUtils; import com.boardgamegeek.util.HelpUtils; import com.boardgamegeek.util.PreferencesUtils; import com.boardgamegeek.util.PresentationUtils; import com.boardgamegeek.util.UIUtils; import com.crashlytics.android.answers.Answers; import com.crashlytics.android.answers.CustomEvent; import com.crashlytics.android.answers.SearchEvent; import com.github.amlcurran.showcaseview.ShowcaseView; import com.github.amlcurran.showcaseview.ShowcaseView.Builder; import com.github.amlcurran.showcaseview.targets.Target; import java.util.ArrayList; import java.util.List; import butterknife.BindView; import butterknife.ButterKnife; import butterknife.Unbinder; import hugo.weaving.DebugLog; import icepick.Icepick; import icepick.State; import retrofit2.Call; public class SearchResultsFragment extends Fragment implements LoaderCallbacks<SearchData>, ActionMode.Callback { private static final int HELP_VERSION = 2; private static final int LOADER_ID = 0; private static final int MESSAGE_QUERY_UPDATE = 1; private static final int QUERY_UPDATE_DELAY_MILLIS = 2000; private static final String KEY_SEARCH_TEXT = "SEARCH_TEXT"; private static final String KEY_SEARCH_EXACT = "SEARCH_EXACT"; @State String previousSearchText; @State boolean previousShouldSearchExact; private SearchResultsAdapter searchResultsAdapter; private Snackbar snackbar; private ShowcaseView showcaseView; private ActionMode actionMode; private Unbinder unbinder; @BindView(R.id.root_container) CoordinatorLayout containerView; @BindView(android.R.id.progress) View progressView; @BindView(android.R.id.empty) TextView emptyView; @BindView(android.R.id.list) RecyclerView recyclerView; private final Handler requeryHandler = new Handler() { @Override public void handleMessage(Message msg) { if (msg.what == MESSAGE_QUERY_UPDATE) { @SuppressWarnings("unchecked") Pair<String, Boolean> pair = (Pair<String, Boolean>) msg.obj; requery(pair.first, pair.second); } } }; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); } @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_search_results, container, false); unbinder = ButterKnife.bind(this, rootView); setUpRecyclerView(); return rootView; } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); Icepick.restoreInstanceState(this, savedInstanceState); final Intent intent = UIUtils.fragmentArgumentsToIntent(getArguments()); if (intent.hasExtra(SearchManager.QUERY)) { previousSearchText = intent.getStringExtra(SearchManager.QUERY); } getLoaderManager().initLoader(LOADER_ID, getLoaderBundle(previousSearchText, previousShouldSearchExact), SearchResultsFragment.this); } @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); Icepick.saveInstanceState(this, outState); } @Override public void onDestroyView() { super.onDestroyView(); if (unbinder != null) unbinder.unbind(); } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { inflater.inflate(R.menu.help, menu); super.onCreateOptionsMenu(menu, inflater); } @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == R.id.menu_help) { showHelp(); return true; } return super.onOptionsItemSelected(item); } private void setUpRecyclerView() { recyclerView.setLayoutManager(new LinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, false)); recyclerView.setHasFixedSize(true); recyclerView.addItemDecoration(new DividerItemDecoration(getActivity(), DividerItemDecoration.VERTICAL)); } @DebugLog private void showHelp() { final Builder builder = HelpUtils.getShowcaseBuilder(getActivity()); if (builder != null) { builder.setContentText(R.string.help_searchresults) .setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { showcaseView.hide(); HelpUtils.updateHelp(getContext(), HelpUtils.HELP_SEARCHRESULTS_KEY, HELP_VERSION); } }); Target viewTarget = getTarget(); builder.setTarget(viewTarget == null ? Target.NONE : viewTarget); showcaseView = builder.build(); showcaseView.setButtonPosition(HelpUtils.getCenterLeftLayoutParams(getActivity())); showcaseView.show(); } } @DebugLog private Target getTarget() { final View child = HelpUtils.getRecyclerViewVisibleChild(recyclerView); return child == null ? null : new SafeViewTarget(child); } @DebugLog private void maybeShowHelp() { if (HelpUtils.shouldShowHelp(getContext(), HelpUtils.HELP_SEARCHRESULTS_KEY, HELP_VERSION)) { new Handler().postDelayed(new Runnable() { @Override public void run() { showHelp(); } }, 100); } } @Override public Loader<SearchData> onCreateLoader(int id, Bundle data) { return new SearchLoader(getActivity(), data.getString(KEY_SEARCH_TEXT), data.getBoolean(KEY_SEARCH_EXACT, true)); } @DebugLog @Override public void onLoadFinished(Loader<SearchData> loader, SearchData data) { AnimationUtils.fadeOut(progressView); if (getActivity() == null) return; int count = data == null ? 0 : data.getCount(); final String searchText = data == null ? "" : data.getSearchText(); boolean isExactMatch = data != null && data.isExactMatch(); if (data != null) { searchResultsAdapter = new SearchResultsAdapter(getActivity(), data.getList(), new Callback() { @Override public boolean onItemClick(int position) { if (actionMode == null) return false; toggleSelection(position); return true; } @Override public boolean onItemLongClick(int position) { if (actionMode != null) return false; actionMode = getActivity().startActionMode(SearchResultsFragment.this); toggleSelection(position); return true; } private void toggleSelection(int position) { searchResultsAdapter.toggleSelection(position); int count = searchResultsAdapter.getSelectedItemCount(); if (count == 0) { actionMode.finish(); } else { actionMode.setTitle(getResources().getQuantityString(R.plurals.msg_games_selected, count, count)); actionMode.invalidate(); } } }); recyclerView.setAdapter(searchResultsAdapter); } else if (searchResultsAdapter != null) { searchResultsAdapter.clear(); } if (data == null) { if (TextUtils.isEmpty(searchText)) { emptyView.setText(R.string.search_initial_help); } else { emptyView.setText(R.string.empty_search); } AnimationUtils.fadeIn(emptyView); AnimationUtils.fadeOut(recyclerView); } else if (data.hasError()) { emptyView.setText(getString(R.string.empty_http_error, data.getErrorMessage())); AnimationUtils.fadeIn(emptyView); AnimationUtils.fadeOut(recyclerView); } else if (data.getCount() == 0) { if (TextUtils.isEmpty(searchText)) { emptyView.setText(R.string.search_initial_help); } else { emptyView.setText(R.string.empty_search); } AnimationUtils.fadeIn(emptyView); AnimationUtils.fadeOut(recyclerView); } else { AnimationUtils.fadeOut(emptyView); AnimationUtils.fadeIn(getActivity(), recyclerView, isResumed()); } maybeShowHelp(); if (TextUtils.isEmpty(searchText)) { if (snackbar != null) { snackbar.dismiss(); } } else { @PluralsRes final int messageId = isExactMatch ? R.plurals.search_results_exact : R.plurals.search_results; if (snackbar == null || !snackbar.isShown()) { snackbar = Snackbar.make(containerView, getResources().getQuantityString(messageId, count, count, searchText), Snackbar.LENGTH_INDEFINITE); snackbar.getView().setBackgroundResource(R.color.dark_blue); snackbar.setActionTextColor(ContextCompat.getColor(getActivity(), R.color.inverse_text)); } else { snackbar.setText(getResources().getQuantityString(messageId, count, count, searchText)); } if (isExactMatch) { snackbar.setAction(R.string.more, new OnClickListener() { @Override public void onClick(View v) { requeryHandler.removeMessages(MESSAGE_QUERY_UPDATE); requery(searchText, false); Answers.getInstance().logCustom(new CustomEvent("SearchMore")); } }); } else { snackbar.setAction("", null); } snackbar.show(); } } @Override public void onLoaderReset(Loader<SearchData> results) { } public void requestQueryUpdate(String query) { AnimationUtils.fadeIn(progressView); if (TextUtils.isEmpty(query)) { requery(query, true); } else { requeryHandler.removeMessages(MESSAGE_QUERY_UPDATE); requeryHandler.sendMessageDelayed(Message.obtain(requeryHandler, MESSAGE_QUERY_UPDATE, new Pair<>(query, true)), QUERY_UPDATE_DELAY_MILLIS); } } public void forceQueryUpdate(String query) { requeryHandler.removeMessages(MESSAGE_QUERY_UPDATE); AnimationUtils.fadeIn(progressView); requery(query, true); } private void requery(@Nullable String query, boolean shouldSearchExact) { if (!isAdded()) return; if (query == null && previousSearchText == null) return; if (previousSearchText != null && previousSearchText.equals(query) && shouldSearchExact == previousShouldSearchExact) return; Answers.getInstance().logSearch(new SearchEvent().putQuery(query)); getLoaderManager().restartLoader(LOADER_ID, getLoaderBundle(query, shouldSearchExact), SearchResultsFragment.this); } @NonNull private Bundle getLoaderBundle(String query, boolean shouldSearchExact) { previousSearchText = query; previousShouldSearchExact = shouldSearchExact; Bundle args = new Bundle(); args.putString(KEY_SEARCH_TEXT, query); args.putBoolean(KEY_SEARCH_EXACT, shouldSearchExact); return args; } private static class SearchLoader extends BggLoader<SearchData> { private final BggService bggService; private final String searchText; private final boolean shouldSearchExact; public SearchLoader(Context context, String searchText, boolean shouldSearchExact) { super(context); bggService = Adapter.createForXml(); this.searchText = searchText; this.shouldSearchExact = shouldSearchExact; } @Override public SearchData loadInBackground() { if (TextUtils.isEmpty(searchText)) { return null; } SearchData response = null; if (shouldSearchExact) { response = new SearchData(bggService.search(searchText, BggService.SEARCH_TYPE_BOARD_GAME, 1), searchText, true); } if (response == null || response.getBody() == null || response.getBody().games == null || response.getBody().games.isEmpty()) { return new SearchData(bggService.search(searchText, BggService.SEARCH_TYPE_BOARD_GAME, 0), searchText, false); } else { return response; } } } static class SearchData extends SafeResponse<SearchResponse> { private final String searchText; private final boolean isExactMatch; public SearchData(Call<SearchResponse> call, String searchText, boolean isExactMatch) { super(call); this.searchText = searchText; this.isExactMatch = isExactMatch; } public String getSearchText() { return searchText; } public boolean isExactMatch() { return isExactMatch; } public int getCount() { if (getBody() == null || getBody().games == null) return 0; return getBody().games.size(); } public List<SearchResult> getList() { if (getBody() == null || getBody().games == null) return new ArrayList<>(); return getBody().games; } } public interface Callback { boolean onItemClick(int position); boolean onItemLongClick(int position); } public static class SearchResultsAdapter extends RecyclerView.Adapter<SearchResultsAdapter.SearchResultViewHolder> { private final LayoutInflater inflater; private final List<SearchResult> results; private final Callback callback; private final SparseBooleanArray selectedItems; public SearchResultsAdapter(Activity activity, List<SearchResult> results, Callback callback) { this.results = results; this.callback = callback; inflater = activity.getLayoutInflater(); selectedItems = new SparseBooleanArray(); setHasStableIds(true); } @Override public SearchResultViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = inflater.inflate(R.layout.row_search, parent, false); return new SearchResultViewHolder(view); } @Override public void onBindViewHolder(SearchResultViewHolder holder, int position) { holder.bind(getItem(position), position); } @Override public int getItemCount() { return results == null ? 0 : results.size(); } @Override public long getItemId(int position) { return position; } public SearchResult getItem(int position) { return results.get(position); } public void clear() { results.clear(); } public class SearchResultViewHolder extends RecyclerView.ViewHolder { @BindView(R.id.name) TextView nameView; @BindView(R.id.year) TextView yearView; @BindView(R.id.game_id) TextView gameIdView; public SearchResultViewHolder(View itemView) { super(itemView); ButterKnife.bind(this, itemView); } public void bind(final SearchResult game, final int position) { if (game == null) return; nameView.setText(game.name); int style; switch (game.getNameType()) { case SearchResult.NAME_TYPE_ALTERNATE: style = Typeface.ITALIC; break; case SearchResult.NAME_TYPE_PRIMARY: case SearchResult.NAME_TYPE_UNKNOWN: default: style = Typeface.NORMAL; break; } nameView.setTypeface(nameView.getTypeface(), style); yearView.setText(PresentationUtils.describeYear(yearView.getContext(), game.getYearPublished())); gameIdView.setText(gameIdView.getContext().getString(R.string.id_list_text, String.valueOf(game.id))); itemView.setActivated(selectedItems.get(position, false)); itemView.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { boolean handled = false; if (callback != null) { handled = callback.onItemClick(position); } if (!handled) { ActivityUtils.launchGame(itemView.getContext(), game.id, game.name); } } }); itemView.setOnLongClickListener(new OnLongClickListener() { @Override public boolean onLongClick(View v) { return callback != null && callback.onItemLongClick(position); } }); } } public void toggleSelection(int position) { if (selectedItems.get(position, false)) { selectedItems.delete(position); } else { selectedItems.put(position, true); } notifyItemChanged(position); } public void clearSelections() { selectedItems.clear(); notifyDataSetChanged(); } public int getSelectedItemCount() { return selectedItems.size(); } public List<Integer> getSelectedItems() { List<Integer> items = new ArrayList<>(selectedItems.size()); for (int i = 0; i < selectedItems.size(); i++) { items.add(selectedItems.keyAt(i)); } return items; } } @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { MenuInflater inflater = mode.getMenuInflater(); inflater.inflate(R.menu.game_context, menu); searchResultsAdapter.clearSelections(); return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { int count = searchResultsAdapter.getSelectedItemCount(); menu.findItem(R.id.menu_log_play).setVisible(count == 1 && PreferencesUtils.showLogPlay(getActivity())); menu.findItem(R.id.menu_log_play_quick).setVisible(PreferencesUtils.showQuickLogPlay(getActivity())); menu.findItem(R.id.menu_link).setVisible(count == 1); return true; } @Override public void onDestroyActionMode(ActionMode mode) { } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { if (searchResultsAdapter == null || searchResultsAdapter.getSelectedItems() == null || searchResultsAdapter.getSelectedItems().size() == 0) { return false; } SearchResult game = searchResultsAdapter.getItem(searchResultsAdapter.getSelectedItems().get(0)); switch (item.getItemId()) { case R.id.menu_log_play: mode.finish(); ActivityUtils.logPlay(getActivity(), game.id, game.name, null, null, false); return true; case R.id.menu_log_play_quick: mode.finish(); String text = getResources().getQuantityString(R.plurals.msg_logging_plays, searchResultsAdapter.getSelectedItemCount()); Toast.makeText(getActivity(), text, Toast.LENGTH_SHORT).show(); for (int position : searchResultsAdapter.getSelectedItems()) { SearchResult g = searchResultsAdapter.getItem(position); ActivityUtils.logQuickPlay(getActivity(), g.id, g.name); } return true; case R.id.menu_share: mode.finish(); final String shareMethod = "Search"; if (searchResultsAdapter.getSelectedItemCount() == 1) { ActivityUtils.shareGame(getActivity(), game.id, game.name, shareMethod); } else { List<Pair<Integer, String>> games = new ArrayList<>(searchResultsAdapter.getSelectedItemCount()); for (int position : searchResultsAdapter.getSelectedItems()) { SearchResult g = searchResultsAdapter.getItem(position); games.add(Pair.create(g.id, g.name)); } ActivityUtils.shareGames(getActivity(), games, shareMethod); } return true; case R.id.menu_link: mode.finish(); ActivityUtils.linkBgg(getActivity(), game.id); return true; } return false; } }