Adds monster collections.

This commit is contained in:
2026-09-07 15:14:23 -07:00
parent 605f4b99f7
commit 51b0245cfe
20 changed files with 1575 additions and 20 deletions

View File

@@ -0,0 +1,180 @@
package com.majinnaibu.monstercards.ui.collections;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavDirections;
import androidx.navigation.Navigation;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar;
import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.ui.dashboard.DashboardRecyclerViewAdapter;
import com.majinnaibu.monstercards.ui.shared.MCFragment;
import com.majinnaibu.monstercards.utils.Logger;
import java.util.List;
import java.util.UUID;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class CollectionDetailFragment extends MCFragment {
private CollectionDetailViewModel mViewModel;
private UUID mCollectionId;
private DashboardRecyclerViewAdapter mAdapter;
private final CompositeDisposable mDisposables = new CompositeDisposable();
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_collection_detail, container, false);
Bundle arguments = getArguments();
assert arguments != null;
mCollectionId = UUID.fromString(CollectionDetailFragmentArgs.fromBundle(arguments).getCollectionId());
mViewModel = new ViewModelProvider(this).get(CollectionDetailViewModel.class);
TextView nameView = root.findViewById(R.id.detail_collection_name);
TextView descriptionView = root.findViewById(R.id.detail_collection_description);
RecyclerView recyclerView = root.findViewById(R.id.collection_monster_list);
FloatingActionButton fab = root.findViewById(R.id.fab_add_monster_to_collection);
if (fab != null) {
fab.setOnClickListener(v -> showAddMonsterDialog());
}
mViewModel.getCollection().observe(getViewLifecycleOwner(), collection -> {
if (collection != null) {
nameView.setText(collection.name);
setTitle(collection.name);
if (!TextUtils.isEmpty(collection.description)) {
descriptionView.setVisibility(View.VISIBLE);
descriptionView.setText(collection.description);
} else {
descriptionView.setVisibility(View.GONE);
}
}
});
mViewModel.getMonsters().observe(getViewLifecycleOwner(), monsters -> {
if (mAdapter != null) {
mAdapter.submitList(monsters);
}
});
setupRecyclerView(recyclerView);
loadCollectionData();
return root;
}
private void loadCollectionData() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.getCollection(mCollectionId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(collection -> {
if (collection != null) {
mViewModel.setCollection(collection);
}
}, Logger::logError));
mDisposables.add(repository.getMonstersForCollection(mCollectionId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(monsters -> mViewModel.setMonsters(monsters), Logger::logError));
}
private void setupRecyclerView(@NonNull RecyclerView recyclerView) {
int columnCount = Math.max(1, getResources().getConfiguration().screenWidthDp / 396);
Context context = requireContext();
GridLayoutManager layoutManager = new GridLayoutManager(context, columnCount);
recyclerView.setLayoutManager(layoutManager);
mAdapter = new DashboardRecyclerViewAdapter(monster -> {
if (monster != null) {
navigateToMonsterDetail(monster.id);
} else {
Logger.logError("Can't navigate to MonsterDetailFragment with a null monster");
}
});
recyclerView.setAdapter(mAdapter);
}
private void showAddMonsterDialog() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.getMonsters()
.firstOrError()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(monsters -> {
if (monsters.isEmpty()) {
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.snackbar_failed_to_create_monster), Snackbar.LENGTH_LONG).show();
}
return;
}
String[] monsterNames = new String[monsters.size()];
for (int i = 0; i < monsters.size(); i++) {
monsterNames[i] = monsters.get(i).name;
}
new AlertDialog.Builder(requireContext())
.setTitle(R.string.action_add_monster)
.setItems(monsterNames, (dialog, which) -> {
Monster selectedMonster = monsters.get(which);
addMonsterToCollection(selectedMonster);
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
}, Logger::logError));
}
private void addMonsterToCollection(Monster monster) {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.addMonsterToCollection(mCollectionId, monster.id)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
View view = getView();
if (view != null) {
Collection collection = mViewModel.getCollection().getValue();
String collectionName = collection != null ? collection.name : "";
Snackbar.make(
view,
getString(R.string.snackbar_monster_added_to_collection, monster.name, collectionName),
Snackbar.LENGTH_LONG)
.show();
}
}, Logger::logError));
}
private void navigateToMonsterDetail(@NonNull UUID monsterId) {
NavDirections action = CollectionDetailFragmentDirections.actionCollectionDetailFragmentToNavigationMonster(monsterId.toString());
Navigation.findNavController(requireView()).navigate(action);
}
@Override
public void onDestroyView() {
super.onDestroyView();
mDisposables.clear();
}
}

View File

@@ -0,0 +1,36 @@
package com.majinnaibu.monstercards.ui.collections;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.Monster;
import java.util.ArrayList;
import java.util.List;
public class CollectionDetailViewModel extends ViewModel {
private final MutableLiveData<Collection> mCollection = new MutableLiveData<>();
private final MutableLiveData<List<Monster>> mMonsters = new MutableLiveData<>(new ArrayList<>());
public CollectionDetailViewModel() {
}
public LiveData<Collection> getCollection() {
return mCollection;
}
public void setCollection(Collection collection) {
mCollection.setValue(collection);
}
public LiveData<List<Monster>> getMonsters() {
return mMonsters;
}
public void setMonsters(List<Monster> monsters) {
mMonsters.setValue(monsters);
}
}

View File

@@ -1,27 +1,158 @@
package com.majinnaibu.monstercards.ui.collections;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.lifecycle.ViewModelProvider;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.navigation.NavDirections;
import androidx.navigation.Navigation;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar;
import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.ui.shared.MCFragment;
import com.majinnaibu.monstercards.ui.shared.SwipeToDeleteCallback;
import com.majinnaibu.monstercards.utils.Logger;
import java.util.UUID;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.observers.DisposableCompletableObserver;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class CollectionsFragment extends MCFragment {
private CollectionsViewModel collectionsViewModel;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
collectionsViewModel = new ViewModelProvider(this).get(CollectionsViewModel.class);
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_collections, container, false);
final TextView textView = root.findViewById(R.id.text_collections);
collectionsViewModel.getText().observe(getViewLifecycleOwner(), textView::setText);
FloatingActionButton fab = root.findViewById(R.id.fab_add_collection);
if (fab != null) {
fab.setOnClickListener(v -> showCreateCollectionDialog());
}
RecyclerView recyclerView = root.findViewById(R.id.collection_list);
if (recyclerView != null) {
setupRecyclerView(recyclerView);
}
return root;
}
private void setupRecyclerView(@NonNull RecyclerView recyclerView) {
Context context = requireContext();
MonsterRepository repository = getMonsterRepository();
CollectionsRecyclerViewAdapter adapter = new CollectionsRecyclerViewAdapter(
context,
repository.getCollectionsWithCount(),
collection -> navigateToCollectionDetail(collection.id),
collection -> repository.deleteCollection(collection)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new DisposableCompletableObserver() {
@Override
public void onComplete() {
}
@Override
public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) {
Logger.logError(e);
}
})
);
recyclerView.setAdapter(adapter);
LinearLayoutManager layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(context, layoutManager.getOrientation());
recyclerView.addItemDecoration(dividerItemDecoration);
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new SwipeToDeleteCallback(context, (position, direction) -> adapter.deleteItem(position), null));
itemTouchHelper.attachToRecyclerView(recyclerView);
}
private void showCreateCollectionDialog() {
Context context = requireContext();
LinearLayout layout = new LinearLayout(context);
layout.setOrientation(LinearLayout.VERTICAL);
int padding = (int) (16 * getResources().getDisplayMetrics().density);
layout.setPadding(padding, padding, padding, padding);
final EditText nameInput = new EditText(context);
nameInput.setHint(R.string.label_collection_name);
layout.addView(nameInput);
final EditText descInput = new EditText(context);
descInput.setHint(R.string.label_collection_description);
layout.addView(descInput);
new AlertDialog.Builder(context)
.setTitle(R.string.title_new_collection)
.setView(layout)
.setPositiveButton(R.string.dialog_create, (dialog, which) -> {
String name = nameInput.getText().toString().trim();
String description = descInput.getText().toString().trim();
if (TextUtils.isEmpty(name)) {
name = getString(R.string.title_new_collection);
}
createCollection(name, description);
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
}
private void createCollection(@NonNull String name, @NonNull String description) {
Collection collection = new Collection(name, description);
MonsterRepository repository = getMonsterRepository();
repository.saveCollection(collection)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new DisposableCompletableObserver() {
@Override
public void onComplete() {
View view = getView();
if (view != null) {
Snackbar.make(
view,
getString(R.string.snackbar_collection_created, collection.name),
Snackbar.LENGTH_LONG)
.setAction("View", v -> navigateToCollectionDetail(collection.id))
.show();
}
}
@Override
public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) {
Logger.logError("Error creating collection", e);
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.snackbar_failed_to_create_collection), Snackbar.LENGTH_LONG).show();
}
}
});
}
private void navigateToCollectionDetail(@NonNull UUID collectionId) {
NavDirections action = CollectionsFragmentDirections.actionNavigationCollectionsToCollectionDetailFragment(collectionId.toString());
Navigation.findNavController(requireView()).navigate(action);
}
}

View File

@@ -0,0 +1,137 @@
package com.majinnaibu.monstercards.ui.collections;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.CollectionWithCount;
import java.util.ArrayList;
import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class CollectionsRecyclerViewAdapter extends RecyclerView.Adapter<CollectionsRecyclerViewAdapter.ViewHolder> {
private final Context mContext;
private final CollectionCallback mOnClick;
private final CollectionCallback mOnDelete;
private final Flowable<List<CollectionWithCount>> mItemsObservable;
private List<CollectionWithCount> mValues;
private Disposable mDisposable;
private final View.OnClickListener mOnClickListener = new View.OnClickListener() {
@Override
public void onClick(@NonNull View view) {
Collection collection = (Collection) view.getTag();
if (mOnClick != null) {
mOnClick.onCallback(collection);
}
}
};
public CollectionsRecyclerViewAdapter(Context context,
Flowable<List<CollectionWithCount>> itemsObservable,
CollectionCallback onClick,
CollectionCallback onDelete) {
mContext = context;
mItemsObservable = itemsObservable;
mOnClick = onClick;
mOnDelete = onDelete;
mValues = new ArrayList<>();
mDisposable = null;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.collection_list_item, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
CollectionWithCount item = mValues.get(position);
Collection collection = item.collection;
holder.mNameView.setText(collection.name);
String countText;
if (item.monsterCount == 1) {
countText = mContext.getString(R.string.format_monster_count_one);
} else {
countText = mContext.getString(R.string.format_monster_count_other, item.monsterCount);
}
holder.mCountView.setText(countText);
holder.mCountView.setVisibility(View.VISIBLE);
if (!TextUtils.isEmpty(collection.description)) {
holder.mDescriptionView.setVisibility(View.VISIBLE);
holder.mDescriptionView.setText(collection.description);
} else {
holder.mDescriptionView.setVisibility(View.GONE);
}
holder.itemView.setTag(collection);
holder.itemView.setOnClickListener(mOnClickListener);
}
@Override
public int getItemCount() {
return mValues.size();
}
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
mDisposable = mItemsObservable
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(collections -> {
mValues = collections;
notifyDataSetChanged();
});
}
@Override
public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
super.onDetachedFromRecyclerView(recyclerView);
if (mDisposable != null) {
mDisposable.dispose();
}
}
public void deleteItem(int position) {
if (mOnDelete != null && position >= 0 && position < mValues.size()) {
Collection collection = mValues.get(position).collection;
mOnDelete.onCallback(collection);
}
}
public interface CollectionCallback {
void onCallback(Collection collection);
}
static class ViewHolder extends RecyclerView.ViewHolder {
final TextView mNameView;
final TextView mDescriptionView;
final TextView mCountView;
ViewHolder(View view) {
super(view);
mNameView = view.findViewById(R.id.collection_name);
mDescriptionView = view.findViewById(R.id.collection_description);
mCountView = view.findViewById(R.id.collection_count);
}
}
}

View File

@@ -38,7 +38,7 @@ public class DashboardRecyclerViewAdapter extends ListAdapter<Monster, Dashboard
};
private final ItemCallback mOnClick;
protected DashboardRecyclerViewAdapter(ItemCallback onClick) {
public DashboardRecyclerViewAdapter(ItemCallback onClick) {
super(DIFF_CALLBACK);
mOnClick = onClick;
}

View File

@@ -18,14 +18,17 @@ import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavDirections;
import androidx.navigation.Navigation;
import com.google.android.material.snackbar.Snackbar;
import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.helpers.CommonMarkHelper;
import com.majinnaibu.monstercards.helpers.StringHelper;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.ui.shared.MCFragment;
import com.majinnaibu.monstercards.utils.Logger;
@@ -33,7 +36,10 @@ import com.majinnaibu.monstercards.utils.Logger;
import java.util.List;
import java.util.UUID;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.observers.DisposableCompletableObserver;
import io.reactivex.rxjava3.observers.DisposableSingleObserver;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class MonsterDetailFragment extends MCFragment {
private ViewHolder mHolder;
@@ -172,10 +178,83 @@ public class MonsterDetailFragment extends MCFragment {
Logger.logWTF("monsterId cannot be null.");
}
return true;
} else if (item.getItemId() == R.id.menu_action_add_to_collection) {
showAddToCollectionDialog();
return true;
}
return super.onOptionsItemSelected(item);
}
private void showAddToCollectionDialog() {
UUID monsterId = mViewModel.getId().getValue();
if (monsterId == null) {
return;
}
MonsterRepository repository = getMonsterRepository();
repository.getCollections()
.firstOrError()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new DisposableSingleObserver<List<Collection>>() {
@Override
public void onSuccess(@io.reactivex.rxjava3.annotations.NonNull List<Collection> collections) {
if (collections.isEmpty()) {
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.no_collections_available), Snackbar.LENGTH_LONG).show();
}
dispose();
return;
}
String[] collectionNames = new String[collections.size()];
for (int i = 0; i < collections.size(); i++) {
collectionNames[i] = collections.get(i).name;
}
new AlertDialog.Builder(requireContext())
.setTitle(R.string.title_select_collection)
.setItems(collectionNames, (dialog, which) -> {
Collection selectedCollection = collections.get(which);
addMonsterToCollection(selectedCollection, monsterId);
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
dispose();
}
@Override
public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) {
Logger.logError(e);
dispose();
}
});
}
private void addMonsterToCollection(@NonNull Collection collection, @NonNull UUID monsterId) {
MonsterRepository repository = getMonsterRepository();
repository.addMonsterToCollection(collection.id, monsterId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new DisposableCompletableObserver() {
@Override
public void onComplete() {
View view = getView();
if (view != null) {
String monsterName = mViewModel.getName().getValue();
Snackbar.make(
view,
getString(R.string.snackbar_monster_added_to_collection, monsterName, collection.name),
Snackbar.LENGTH_LONG)
.show();
}
}
@Override
public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) {
Logger.logError(e);
}
});
}
private static class ViewHolder {
final TextView name;
final TextView meta;