From 1ae81b03b0b9cf62ee3237c5762a583b46b87839 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Tue, 31 Aug 2021 21:15:27 -0700 Subject: [PATCH] Makes search work. --- .../ui/search/SearchFragment.java | 78 +++++++++--- .../SearchResultsRecyclerViewAdapter.java | 91 +++++--------- .../ui/search/SearchViewModel.java | 117 ++++++++++++++++-- 3 files changed, 201 insertions(+), 85 deletions(-) diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchFragment.java b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchFragment.java index 12ab602..f7699ef 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchFragment.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchFragment.java @@ -1,34 +1,49 @@ package com.majinnaibu.monstercards.ui.search; +import android.content.Context; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.EditText; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavDirections; +import androidx.navigation.Navigation; +import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import com.majinnaibu.monstercards.R; -import com.majinnaibu.monstercards.data.MonsterRepository; +import com.majinnaibu.monstercards.databinding.FragmentSearchBinding; +import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.ui.shared.MCFragment; +import com.majinnaibu.monstercards.utils.Logger; + +import java.util.List; public class SearchFragment extends MCFragment { + private SearchViewModel mViewModel; + private ViewHolder mHolder; + private SearchResultsRecyclerViewAdapter mAdapter; - public View onCreateView(@NonNull LayoutInflater inflater, - ViewGroup container, Bundle savedInstanceState) { - View root = inflater.inflate(R.layout.fragment_search, container, false); - MonsterRepository repository = this.getMonsterRepository(); - SearchResultsRecyclerViewAdapter adapter = new SearchResultsRecyclerViewAdapter(repository, null); - final RecyclerView recyclerView = root.findViewById(R.id.monster_list); - assert recyclerView != null; - setupRecyclerView(recyclerView, adapter); + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + mViewModel = new ViewModelProvider(this).get(SearchViewModel.class); + FragmentSearchBinding binding = FragmentSearchBinding.inflate(inflater, container, false); + mHolder = new ViewHolder(binding); + // TODO: set the title with setTitle(...) + setupMonsterList(binding.monsterList); + setupFilterBox(binding.searchQuery); + return binding.getRoot(); + } - final TextView textView = root.findViewById(R.id.search_query); - textView.addTextChangedListener(new TextWatcher() { + private void setupFilterBox(@NonNull TextView textBox) { + textBox.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { } @@ -39,15 +54,42 @@ public class SearchFragment extends MCFragment { @Override public void afterTextChanged(Editable editable) { - adapter.doSearch(textView.getText().toString()); + mViewModel.setFilterText(textBox.getText().toString()); } }); - - return root; } - private void setupRecyclerView(@NonNull RecyclerView recyclerView, @NonNull SearchResultsRecyclerViewAdapter adapter) { - recyclerView.setAdapter(adapter); - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + private void setupMonsterList(@NonNull RecyclerView recyclerView) { + Context context = requireContext(); + LinearLayoutManager layoutManager = new LinearLayoutManager(context); + recyclerView.setLayoutManager(layoutManager); + + LiveData> monsterData = mViewModel.getMatchedMonsters(); + mAdapter = new SearchResultsRecyclerViewAdapter(this::navigateToMonsterDetail); + if (monsterData != null) { + monsterData.observe(getViewLifecycleOwner(), monsters -> mAdapter.submitList(monsters)); + } + recyclerView.setAdapter(mAdapter); + DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(context, layoutManager.getOrientation()); + recyclerView.addItemDecoration(dividerItemDecoration); + } + + public void navigateToMonsterDetail(Monster monster) { + if (monster == null) { + NavDirections action = SearchFragmentDirections.actionNavigationSearchToNavigationMonster(monster.id.toString()); + Navigation.findNavController(requireView()).navigate(action); + } else { + Logger.logError("Can't navigate to MonsterDetail without a monster."); + } + } + + private static class ViewHolder { + final RecyclerView monsterList; + final EditText filterQuery; + + public ViewHolder(FragmentSearchBinding binding) { + monsterList = binding.monsterList; + filterQuery = binding.searchQuery; + } } } diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchResultsRecyclerViewAdapter.java b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchResultsRecyclerViewAdapter.java index aa6038f..2eabd2a 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchResultsRecyclerViewAdapter.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchResultsRecyclerViewAdapter.java @@ -1,88 +1,63 @@ package com.majinnaibu.monstercards.ui.search; import android.view.LayoutInflater; -import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; -import com.majinnaibu.monstercards.R; -import com.majinnaibu.monstercards.data.MonsterRepository; +import com.majinnaibu.monstercards.databinding.SimpleListItemBinding; import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.utils.ItemCallback; -import com.majinnaibu.monstercards.utils.Logger; -import org.jetbrains.annotations.NotNull; - -import java.util.ArrayList; -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.disposables.Disposable; - -public class SearchResultsRecyclerViewAdapter extends RecyclerView.Adapter { - private final MonsterRepository mRepository; - private final ItemCallback mOnClickHandler; - private String mSearchText; - private List mValues; - private Disposable mSubscriptionHandler; - public SearchResultsRecyclerViewAdapter(MonsterRepository repository, - ItemCallback onClick) { - mRepository = repository; - mSearchText = ""; - mValues = new ArrayList<>(); - mOnClickHandler = onClick; - mSubscriptionHandler = null; - - doSearch(mSearchText); - } - - public void doSearch(String searchText) { - if (mSubscriptionHandler != null && !mSubscriptionHandler.isDisposed()) { - mSubscriptionHandler.dispose(); +public class SearchResultsRecyclerViewAdapter extends ListAdapter { + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull Monster oldItem, @NonNull Monster newItem) { + return Monster.areItemsTheSame(oldItem, newItem); } - mSearchText = searchText; - Flowable> foundMonsters = mRepository.searchMonsters(mSearchText); - mSubscriptionHandler = foundMonsters.subscribe(monsters -> { - mValues = monsters; - notifyDataSetChanged(); - }, - throwable -> Logger.logError("Error performing search", throwable)); + + @Override + public boolean areContentsTheSame(@NonNull Monster oldItem, @NonNull Monster newItem) { + return Monster.areContentsTheSame(oldItem, newItem); + } + }; + private final ItemCallback mOnClick; + + public SearchResultsRecyclerViewAdapter(ItemCallback onClick) { + super(DIFF_CALLBACK); + mOnClick = onClick; } - @NotNull + @NonNull @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.simple_list_item, parent, false); - return new ViewHolder(view); + SimpleListItemBinding binding = SimpleListItemBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new ViewHolder(binding); } @Override - public void onBindViewHolder(final ViewHolder holder, int position) { - Monster monster = mValues.get(position); - holder.mContentView.setText(monster.name); - holder.itemView.setTag(monster); + public void onBindViewHolder(@NonNull final ViewHolder holder, int position) { + Monster monster = getItem(position); + holder.item = monster; + holder.contentView.setText(monster.name); holder.itemView.setOnClickListener(view -> { - if (mOnClickHandler != null) { - mOnClickHandler.onItem(monster); + if (mOnClick != null) { + mOnClick.onItem(holder.item); } }); } - @Override - public int getItemCount() { - return mValues.size(); - } - public static class ViewHolder extends RecyclerView.ViewHolder { - final TextView mContentView; + final TextView contentView; + Monster item; - ViewHolder(View view) { - super(view); - mContentView = view.findViewById(R.id.content); + ViewHolder(SimpleListItemBinding binding) { + super(binding.getRoot()); + contentView = binding.content; } } } diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchViewModel.java b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchViewModel.java index e8fc25d..4c8fcb7 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchViewModel.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/search/SearchViewModel.java @@ -1,19 +1,118 @@ package com.majinnaibu.monstercards.ui.search; +import android.app.Application; + +import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.ViewModel; -public class SearchViewModel extends ViewModel { +import com.majinnaibu.monstercards.AppDatabase; +import com.majinnaibu.monstercards.helpers.StringHelper; +import com.majinnaibu.monstercards.models.Monster; +import com.majinnaibu.monstercards.utils.Logger; - private MutableLiveData mSearchQuery; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; - public SearchViewModel() { - mSearchQuery = new MutableLiveData<>(); - mSearchQuery.setValue(""); +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.schedulers.Schedulers; +import io.reactivex.rxjava3.subscribers.DisposableSubscriber; + +public class SearchViewModel extends AndroidViewModel { + private final MutableLiveData> mAllMonsters; + private final MediatorLiveData> mFilteredMonsters; + private final MutableLiveData mFilterText; + private final AppDatabase mDB; + + public SearchViewModel(Application application) { + super(application); + mDB = AppDatabase.getInstance(application); + mAllMonsters = new MutableLiveData<>(new ArrayList<>()); + mFilterText = new MutableLiveData<>(""); + mFilteredMonsters = new MediatorLiveData<>(); + mFilteredMonsters.addSource( + mAllMonsters, + allMonsters -> mFilteredMonsters.setValue( + filterMonsters(allMonsters, mFilterText.getValue()))); + mFilteredMonsters.addSource( + mFilterText, + filterText -> mFilteredMonsters.setValue( + filterMonsters(mAllMonsters.getValue(), filterText))); + mDB.monsterDAO() + .getAll() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .subscribe(new DisposableSubscriber>() { + @Override + public void onNext(List monsters) { + mAllMonsters.setValue(monsters); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onComplete() { + } + }); } - public LiveData getSearchQuery() { - return mSearchQuery; + private boolean monsterMatchesFilter(Monster monster, String filterText) { + if (StringHelper.isNullOrEmpty(filterText)) { + return true; + } + + if (StringHelper.containsCaseInsensitive(monster.name, filterText)) { + return true; + } + + if (StringHelper.containsCaseInsensitive(monster.size, filterText)) { + return true; + } + + if (StringHelper.containsCaseInsensitive(monster.type, filterText)) { + return true; + } + + if (StringHelper.containsCaseInsensitive(monster.subtype, filterText)) { + return true; + } + + if (StringHelper.containsCaseInsensitive(monster.alignment, filterText)) { + return true; + } + + return false; } -} \ No newline at end of file + + private List filterMonsters(List allMonsters, String filterText) { + ArrayList filteredMonsters = new ArrayList<>(); + filterText = filterText.toLowerCase(Locale.ROOT); + if (allMonsters != null) { + for (Monster monster : allMonsters) { + // TODO: do the filtering like the iOS app does. + Logger.logUnimplementedFeature("do the filtering like the iOS app does"); + // TODO: consider splitting search text into words and if each word appears in any of these fields return true e.g, "large demon" would match large in size and demon in type. + // TODO: add tags and search by tags + // TODO: add a display of what fields matched on each item in the results + // TODO: make the criteria configurable from this screen + // TODO: find a way to add challenge rating as a search criteria + if (monsterMatchesFilter(monster, filterText)) { + filteredMonsters.add(monster); + } + } + } + return filteredMonsters; + } + + public LiveData> getMatchedMonsters() { + return mFilteredMonsters; + } + + public void setFilterText(String filterText) { + mFilterText.setValue(filterText); + } +}