From 274d98160a9a5cfdf73770169fbe08e3a2932cff Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 9 Jan 2020 17:21:56 -0600 Subject: [PATCH] Change tags with multi-select --- .../java/org/tasks/data/TagDataDaoTest.java | 63 +++++- .../java/org/tasks/makers/TagDataMaker.java | 2 + .../java/org/tasks/makers/TagMaker.java | 28 +-- .../astrid/activity/TaskListFragment.java | 38 +++- .../todoroo/astrid/adapter/TaskAdapter.java | 3 +- .../todoroo/astrid/tags/TagsControlSet.java | 4 +- .../main/java/org/tasks/data/TagDataDao.java | 64 ++++++- app/src/main/java/org/tasks/db/DbUtils.java | 2 +- .../org/tasks/tags/CheckBoxTriStates.java | 179 ++++++++++++++++++ .../org/tasks/tags/TagPickerActivity.java | 23 ++- .../org/tasks/tags/TagPickerViewHolder.java | 26 ++- .../org/tasks/tags/TagPickerViewModel.java | 31 ++- .../org/tasks/tags/TagRecyclerAdapter.java | 8 +- .../ic_indeterminate_check_box_24px.xml | 5 + app/src/main/res/layout/row_tag_picker.xml | 2 +- app/src/main/res/menu/menu_multi_select.xml | 6 + 16 files changed, 434 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/org/tasks/tags/CheckBoxTriStates.java create mode 100644 app/src/main/res/drawable/ic_indeterminate_check_box_24px.xml diff --git a/app/src/androidTest/java/org/tasks/data/TagDataDaoTest.java b/app/src/androidTest/java/org/tasks/data/TagDataDaoTest.java index 309c9648b..9852b988d 100644 --- a/app/src/androidTest/java/org/tasks/data/TagDataDaoTest.java +++ b/app/src/androidTest/java/org/tasks/data/TagDataDaoTest.java @@ -1,24 +1,31 @@ package org.tasks.data; +import static com.google.common.collect.Sets.newHashSet; import static com.natpryce.makeiteasy.MakeItEasy.with; import static java.util.Collections.singletonList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.tasks.makers.TagDataMaker.NAME; +import static org.tasks.makers.TagDataMaker.UID; import static org.tasks.makers.TagDataMaker.newTagData; import static org.tasks.makers.TagMaker.TAGDATA; +import static org.tasks.makers.TagMaker.TAGUID; import static org.tasks.makers.TagMaker.TASK; -import static org.tasks.makers.TagMaker.newTag; +import static org.tasks.makers.TaskMaker.ID; import static org.tasks.makers.TaskMaker.newTask; +import androidx.core.util.Pair; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import com.todoroo.astrid.dao.TaskDao; import com.todoroo.astrid.data.Task; +import java.util.Set; import javax.inject.Inject; import org.junit.Test; import org.junit.runner.RunWith; import org.tasks.injection.InjectingTestCase; import org.tasks.injection.TestComponent; +import org.tasks.makers.TagMaker; @RunWith(AndroidJUnit4.class) public class TagDataDaoTest extends InjectingTestCase { @@ -80,12 +87,62 @@ public class TagDataDaoTest extends InjectingTestCase { tagDataDao.createNew(tagOne); tagDataDao.createNew(tagTwo); - tagDao.insert(newTag(with(TAGDATA, tagOne), with(TASK, taskOne))); - tagDao.insert(newTag(with(TAGDATA, tagTwo), with(TASK, taskTwo))); + tagDao.insert(TagMaker.newTag(with(TAGDATA, tagOne), with(TASK, taskOne))); + tagDao.insert(TagMaker.newTag(with(TAGDATA, tagTwo), with(TASK, taskTwo))); assertEquals(singletonList(tagOne), tagDataDao.getTagDataForTask(taskOne.getId())); } + @Test + public void getEmptyTagSelections() { + Pair, Set> selections = tagDataDao.getTagSelections(ImmutableList.of(1L)); + assertTrue(selections.first.isEmpty()); + assertTrue(selections.second.isEmpty()); + } + + @Test + public void getPartialTagSelections() { + newTag(1, "tag1", "tag2"); + newTag(2, "tag2", "tag3"); + + + assertEquals( + newHashSet("tag1", "tag3"), tagDataDao.getTagSelections(ImmutableList.of(1L, 2L)).first); + } + + @Test + public void getEmptyPartialSelections() { + newTag(1, "tag1"); + newTag(2, "tag1"); + + assertTrue(tagDataDao.getTagSelections(ImmutableList.of(1L, 2L)).first.isEmpty()); + } + + @Test + public void getCommonTagSelections() { + newTag(1, "tag1", "tag2"); + newTag(2, "tag2", "tag3"); + + assertEquals( + newHashSet("tag2"), tagDataDao.getTagSelections(ImmutableList.of(1L, 2L)).second); + } + + @Test + public void getEmptyCommonSelections() { + newTag(1, "tag1"); + newTag(2, "tag2"); + + assertTrue(tagDataDao.getTagSelections(ImmutableList.of(1L, 2L)).second.isEmpty()); + } + + private void newTag(long taskId, String... tags) { + Task task = newTask(with(ID, taskId)); + taskDao.createNew(task); + for (String tag : tags) { + tagDao.insert(TagMaker.newTag(with(TASK, task), with(TAGUID, tag))); + } + } + @Override protected void inject(TestComponent component) { component.inject(this); diff --git a/app/src/androidTest/java/org/tasks/makers/TagDataMaker.java b/app/src/androidTest/java/org/tasks/makers/TagDataMaker.java index 549ba963b..dd17712c1 100644 --- a/app/src/androidTest/java/org/tasks/makers/TagDataMaker.java +++ b/app/src/androidTest/java/org/tasks/makers/TagDataMaker.java @@ -11,10 +11,12 @@ import org.tasks.data.TagData; public class TagDataMaker { public static final Property NAME = newProperty(); + public static final Property UID = newProperty(); private static final Instantiator instantiator = lookup -> { TagData tagData = new TagData(); tagData.setName(lookup.valueOf(NAME, "tag")); + tagData.setRemoteId(lookup.valueOf(UID, (String) null)); return tagData; }; diff --git a/app/src/androidTest/java/org/tasks/makers/TagMaker.java b/app/src/androidTest/java/org/tasks/makers/TagMaker.java index 3b1629f92..8316e64ab 100644 --- a/app/src/androidTest/java/org/tasks/makers/TagMaker.java +++ b/app/src/androidTest/java/org/tasks/makers/TagMaker.java @@ -12,21 +12,25 @@ import org.tasks.data.TagData; public class TagMaker { - public static final Property NAME = newProperty(); public static final Property TAGDATA = newProperty(); public static final Property TASK = newProperty(); + public static final Property TAGUID = newProperty(); - private static final Instantiator instantiator = lookup -> { - Tag tag = new Tag(); - Task task = lookup.valueOf(TASK, (Task) null); - assert(task != null); - tag.setTask(task.getId()); - tag.setTaskUid(task.getUuid()); - TagData tagData = lookup.valueOf(TAGDATA, (TagData) null); - assert(tagData != null); - tag.setTagUid(tagData.getRemoteId()); - return tag; - }; + private static final Instantiator instantiator = + lookup -> { + Tag tag = new Tag(); + Task task = lookup.valueOf(TASK, (Task) null); + assert (task != null); + tag.setTask(task.getId()); + tag.setTaskUid(task.getUuid()); + tag.setTagUid(lookup.valueOf(TAGUID, (String) null)); + TagData tagData = lookup.valueOf(TAGDATA, (TagData) null); + if (tagData != null) { + tag.setTagUid(tagData.getRemoteId()); + } + assert(tag.getTagUid() != null); + return tag; + }; @SafeVarargs public static Tag newTag(PropertyValue... properties) { diff --git a/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.java b/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.java index 88118189b..97a884f43 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.java +++ b/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.java @@ -8,6 +8,7 @@ package com.todoroo.astrid.activity; import static android.app.Activity.RESULT_OK; import static androidx.core.content.ContextCompat.getColor; +import static com.google.common.collect.Lists.newArrayList; import static com.todoroo.andlib.utility.AndroidUtilities.assertMainThread; import static org.tasks.activities.RemoteListSupportPicker.newRemoteListSupportPicker; import static org.tasks.caldav.CaldavCalendarSettingsActivity.EXTRA_CALDAV_CALENDAR; @@ -36,6 +37,7 @@ import androidx.appcompat.widget.SearchView.OnQueryTextListener; import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener; import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.util.Pair; import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; @@ -69,7 +71,9 @@ import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; import io.reactivex.subjects.PublishSubject; +import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; import javax.inject.Inject; import org.tasks.LocalBroadcastManager; @@ -81,6 +85,7 @@ import org.tasks.activities.TagSettingsActivity; import org.tasks.analytics.Tracker; import org.tasks.analytics.Tracking; import org.tasks.caldav.CaldavCalendarSettingsActivity; +import org.tasks.data.TagDataDao; import org.tasks.dialogs.DialogBuilder; import org.tasks.dialogs.SortDialog; import org.tasks.injection.ForActivity; @@ -90,6 +95,7 @@ import org.tasks.intents.TaskIntents; import org.tasks.preferences.Device; import org.tasks.preferences.Preferences; import org.tasks.sync.SyncAdapters; +import org.tasks.tags.TagPickerActivity; import org.tasks.tasklist.DragAndDropRecyclerAdapter; import org.tasks.tasklist.PagedListRecyclerAdapter; import org.tasks.tasklist.TaskListRecyclerAdapter; @@ -121,6 +127,7 @@ public final class TaskListFragment extends InjectingFragment private static final int REQUEST_MOVE_TASKS = 10103; private static final int REQUEST_FILTER_SETTINGS = 10104; private static final int REQUEST_TAG_SETTINGS = 10105; + private static final int REQUEST_TAG_TASKS = 10106; private static final int SEARCH_DEBOUNCE_TIMEOUT = 300; private final RefreshReceiver refreshReceiver = new RefreshReceiver(); @@ -141,6 +148,7 @@ public final class TaskListFragment extends InjectingFragment @Inject TaskAdapterProvider taskAdapterProvider; @Inject TaskDao taskDao; @Inject TaskDuplicator taskDuplicator; + @Inject TagDataDao tagDataDao; @BindView(R.id.swipe_layout) SwipeRefreshLayout swipeRefreshLayout; @@ -588,6 +596,16 @@ public final class TaskListFragment extends InjectingFragment } } break; + case REQUEST_TAG_TASKS: + if (resultCode == RESULT_OK) { + tagDataDao.applyTags( + taskDao.fetch( + (ArrayList) data.getSerializableExtra(TagPickerActivity.EXTRA_TASKS)), + data.getParcelableArrayListExtra(TagPickerActivity.EXTRA_PARTIALLY_SELECTED), + data.getParcelableArrayListExtra(TagPickerActivity.EXTRA_SELECTED)); + finishActionMode(); + } + break; default: super.onActivityResult(requestCode, resultCode, data); } @@ -660,9 +678,21 @@ public final class TaskListFragment extends InjectingFragment @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + ArrayList selected = taskAdapter.getSelected(); switch (item.getItemId()) { + case R.id.edit_tags: + Pair, Set> tags = tagDataDao.getTagSelections(selected); + Intent intent = new Intent(context, TagPickerActivity.class); + intent.putExtra(TagPickerActivity.EXTRA_TASKS, selected); + intent.putParcelableArrayListExtra( + TagPickerActivity.EXTRA_PARTIALLY_SELECTED, + newArrayList(tagDataDao.getByUuid(tags.first))); + intent.putParcelableArrayListExtra( + TagPickerActivity.EXTRA_SELECTED, newArrayList(tagDataDao.getByUuid(tags.second))); + startActivityForResult(intent, REQUEST_TAG_TASKS); + return true; case R.id.move_tasks: - Filter singleFilter = taskMover.getSingleFilter(taskAdapter.getSelected()); + Filter singleFilter = taskMover.getSingleFilter(selected); (singleFilter == null ? newRemoteListSupportPicker(this, REQUEST_MOVE_TASKS) : newRemoteListSupportPicker(singleFilter, this, REQUEST_MOVE_TASKS)) @@ -672,8 +702,7 @@ public final class TaskListFragment extends InjectingFragment dialogBuilder .newDialog(R.string.delete_selected_tasks) .setPositiveButton( - android.R.string.ok, - (dialogInterface, i) -> deleteSelectedItems(taskAdapter.getSelected())) + android.R.string.ok, (dialogInterface, i) -> deleteSelectedItems(selected)) .setNegativeButton(android.R.string.cancel, null) .show(); return true; @@ -681,8 +710,7 @@ public final class TaskListFragment extends InjectingFragment dialogBuilder .newDialog(R.string.copy_selected_tasks) .setPositiveButton( - android.R.string.ok, - ((dialogInterface, i) -> copySelectedItems(taskAdapter.getSelected()))) + android.R.string.ok, ((dialogInterface, i) -> copySelectedItems(selected))) .setNegativeButton(android.R.string.cancel, null) .show(); return true; diff --git a/app/src/main/java/com/todoroo/astrid/adapter/TaskAdapter.java b/app/src/main/java/com/todoroo/astrid/adapter/TaskAdapter.java index 607c457f6..0072a9ae4 100644 --- a/app/src/main/java/com/todoroo/astrid/adapter/TaskAdapter.java +++ b/app/src/main/java/com/todoroo/astrid/adapter/TaskAdapter.java @@ -10,6 +10,7 @@ import static com.google.common.collect.Lists.newArrayList; import static com.google.common.primitives.Longs.asList; import com.todoroo.astrid.data.Task; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -39,7 +40,7 @@ public class TaskAdapter { return selected.size(); } - public List getSelected() { + public ArrayList getSelected() { return newArrayList(selected); } diff --git a/app/src/main/java/com/todoroo/astrid/tags/TagsControlSet.java b/app/src/main/java/com/todoroo/astrid/tags/TagsControlSet.java index 2acae061b..378e499c3 100644 --- a/app/src/main/java/com/todoroo/astrid/tags/TagsControlSet.java +++ b/app/src/main/java/com/todoroo/astrid/tags/TagsControlSet.java @@ -114,7 +114,7 @@ public final class TagsControlSet extends TaskEditControlFragment { @OnClick(R.id.tag_row) void onClickRow() { Intent intent = new Intent(getContext(), TagPickerActivity.class); - intent.putParcelableArrayListExtra(TagPickerActivity.EXTRA_TAGS, selectedTags); + intent.putParcelableArrayListExtra(TagPickerActivity.EXTRA_SELECTED, selectedTags); startActivityForResult(intent, REQUEST_TAG_PICKER_ACTIVITY); } @@ -164,7 +164,7 @@ public final class TagsControlSet extends TaskEditControlFragment { public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { if (requestCode == REQUEST_TAG_PICKER_ACTIVITY) { if (resultCode == RESULT_OK && data != null) { - selectedTags = data.getParcelableArrayListExtra(TagPickerActivity.EXTRA_TAGS); + selectedTags = data.getParcelableArrayListExtra(TagPickerActivity.EXTRA_SELECTED); refreshDisplayView(); } } else { diff --git a/app/src/main/java/org/tasks/data/TagDataDao.java b/app/src/main/java/org/tasks/data/TagDataDao.java index 142738278..0194cfb38 100644 --- a/app/src/main/java/org/tasks/data/TagDataDao.java +++ b/app/src/main/java/org/tasks/data/TagDataDao.java @@ -1,5 +1,14 @@ package org.tasks.data; +import static com.google.common.collect.Iterables.concat; +import static com.google.common.collect.Iterables.transform; +import static com.google.common.collect.Lists.newArrayList; +import static com.google.common.collect.Sets.difference; +import static com.google.common.collect.Sets.newHashSet; +import static org.tasks.db.DbUtils.MAX_SQLITE_ARGS; +import static org.tasks.db.DbUtils.batch; + +import androidx.core.util.Pair; import androidx.lifecycle.LiveData; import androidx.room.Dao; import androidx.room.Delete; @@ -7,11 +16,13 @@ import androidx.room.Insert; import androidx.room.Query; import androidx.room.Transaction; import androidx.room.Update; +import com.google.common.collect.Collections2; import com.todoroo.astrid.data.Task; import com.todoroo.astrid.helper.UUIDHelper; -import io.reactivex.Single; +import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Set; import org.tasks.filters.AlphanumComparator; import org.tasks.filters.TagFilters; @@ -48,6 +59,9 @@ public abstract class TagDataDao { @Query("SELECT * FROM tagdata WHERE remoteId = :uuid LIMIT 1") public abstract TagData getByUuid(String uuid); + @Query("SELECT * FROM tagdata WHERE remoteId IN (:uuids)") + public abstract List getByUuid(Collection uuids); + @Query("SELECT * FROM tagdata WHERE name IS NOT NULL AND name != '' ORDER BY UPPER(name) ASC") public abstract List tagDataOrderedByName(); @@ -57,6 +71,51 @@ public abstract class TagDataDao { @Query("DELETE FROM tags WHERE tag_uid = :tagUid") abstract void deleteTags(String tagUid); + @Query("DELETE FROM tags WHERE task IN (:tasks) AND tag_uid NOT IN (:tagUids)") + abstract void keepTags(List tasks, List tagUids); + + public Pair, Set> getTagSelections(List tasks) { + List allTags = getAllTags(tasks); + Collection> tags = Collections2.transform(allTags, t -> newHashSet(t.split(","))); + Set partialTags = newHashSet(concat(tags)); + Set commonTags = null; + if (tags.isEmpty()) { + commonTags = Collections.emptySet(); + } else { + for (Set s : tags) { + if (commonTags == null) { + commonTags = s; + } else { + commonTags.retainAll(s); + } + } + } + partialTags.removeAll(commonTags); + return Pair.create(partialTags, commonTags); + } + + @Query( + "SELECT IFNULL(GROUP_CONCAT(DISTINCT(tag_uid)), '') FROM tasks" + + " INNER JOIN tags ON tags.task = tasks._id" + + " WHERE tasks._id IN (:tasks)" + + " GROUP BY tasks._id") + abstract List getAllTags(List tasks); + + @Transaction + public void applyTags(List tasks, List partiallySelected, List selected) { + List keep = + newArrayList(transform(concat(partiallySelected, selected), TagData::getRemoteId)); + Iterable ids = transform(tasks, Task::getId); + batch(ids, MAX_SQLITE_ARGS - keep.size(), b -> keepTags(b, keep)); + for (Task task : tasks) { + Set added = + difference(newHashSet(selected), newHashSet(getTagDataForTask(task.getId()))); + if (added.size() > 0) { + insert(transform(added, td -> new Tag(task, td))); + } + } + } + @Transaction public void delete(TagData tagData) { deleteTags(tagData.getRemoteId()); @@ -81,6 +140,9 @@ public abstract class TagDataDao { @Insert abstract long insert(TagData tag); + @Insert + public abstract void insert(Iterable tags); + public void createNew(TagData tag) { if (Task.isUuidEmpty(tag.getRemoteId())) { tag.setRemoteId(UUIDHelper.newUUID()); diff --git a/app/src/main/java/org/tasks/db/DbUtils.java b/app/src/main/java/org/tasks/db/DbUtils.java index 26df62ab0..a81a6da50 100644 --- a/app/src/main/java/org/tasks/db/DbUtils.java +++ b/app/src/main/java/org/tasks/db/DbUtils.java @@ -12,7 +12,7 @@ import org.tasks.Callback; public class DbUtils { - private static final int MAX_SQLITE_ARGS = 990; + public static final int MAX_SQLITE_ARGS = 990; public static List collect(Collection items, Function, List> func) { if (items.size() < MAX_SQLITE_ARGS) { diff --git a/app/src/main/java/org/tasks/tags/CheckBoxTriStates.java b/app/src/main/java/org/tasks/tags/CheckBoxTriStates.java new file mode 100644 index 000000000..f6a5dd3f8 --- /dev/null +++ b/app/src/main/java/org/tasks/tags/CheckBoxTriStates.java @@ -0,0 +1,179 @@ +package org.tasks.tags; + +import static org.tasks.preferences.ResourceResolver.getData; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.widget.CompoundButton; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatCheckBox; +import androidx.core.content.res.ResourcesCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import org.tasks.R; + +public class CheckBoxTriStates extends AppCompatCheckBox { + + private int alpha; + private State state; + private OnCheckedChangeListener clientListener; + + private final OnCheckedChangeListener privateListener = + new CompoundButton.OnCheckedChangeListener() { + + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + switch (state) { + case PARTIALLY_CHECKED: + case UNCHECKED: + setState(State.CHECKED, true); + break; + case CHECKED: + setState(State.UNCHECKED, true); + break; + } + } + }; + + public CheckBoxTriStates(Context context) { + super(context); + init(); + } + + public CheckBoxTriStates(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public CheckBoxTriStates(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public State getState() { + return state; + } + + public void setState(State state, boolean notify) { + if (this.state != state) { + this.state = state; + + if (notify && this.clientListener != null) { + this.clientListener.onCheckedChanged(this, this.isChecked()); + } + } + + updateBtn(); + } + + @Override + public void setOnCheckedChangeListener(@Nullable OnCheckedChangeListener listener) { + if (this.privateListener != listener) { + this.clientListener = listener; + } + + super.setOnCheckedChangeListener(privateListener); + } + + @Override + public Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + + SavedState ss = new SavedState(superState); + + ss.state = state.ordinal(); + + return ss; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + this.state = State.values()[ss.state]; + updateBtn(); + requestLayout(); + } + + private void init() { + alpha = (int) (255 * ResourcesCompat.getFloat(getResources(), R.dimen.alpha_secondary)); + setState(State.UNCHECKED, false); + setOnCheckedChangeListener(this.privateListener); + } + + private void updateBtn() { + int btnDrawable; + int alpha = 255; + switch (state) { + case PARTIALLY_CHECKED: + btnDrawable = R.drawable.ic_indeterminate_check_box_24px; + break; + case CHECKED: + btnDrawable = R.drawable.ic_outline_check_box_24px; + break; + default: + btnDrawable = R.drawable.ic_outline_check_box_outline_blank_24px; + alpha = this.alpha; + break; + } + Drawable original = getResources().getDrawable(btnDrawable); + Drawable drawable = original.mutate(); + drawable.setAlpha(alpha); + DrawableCompat.setTint( + drawable, + state == State.UNCHECKED + ? getResources().getColor(R.color.icon_tint) + : getData(getContext(), R.attr.colorAccent)); + + setButtonDrawable(drawable); + } + + public enum State { + PARTIALLY_CHECKED, + CHECKED, + UNCHECKED + } + + static class SavedState extends BaseSavedState { + @SuppressWarnings("hiding") + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + + int state; + + SavedState(Parcelable superState) { + super(superState); + } + + private SavedState(Parcel in) { + super(in); + state = in.readInt(); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeValue(state); + } + + @Override + public String toString() { + return "CheckboxTriState.SavedState{" + + Integer.toHexString(System.identityHashCode(this)) + + " state=" + + state + + "}"; + } + } +} diff --git a/app/src/main/java/org/tasks/tags/TagPickerActivity.java b/app/src/main/java/org/tasks/tags/TagPickerActivity.java index ae7781a95..b5af2e885 100644 --- a/app/src/main/java/org/tasks/tags/TagPickerActivity.java +++ b/app/src/main/java/org/tasks/tags/TagPickerActivity.java @@ -18,6 +18,7 @@ import org.tasks.billing.Inventory; import org.tasks.data.TagData; import org.tasks.injection.ActivityComponent; import org.tasks.injection.ThemedInjectingAppCompatActivity; +import org.tasks.tags.CheckBoxTriStates.State; import org.tasks.themes.Theme; import org.tasks.themes.ThemeCache; import org.tasks.themes.ThemeColor; @@ -25,7 +26,9 @@ import org.tasks.ui.MenuColorizer; public class TagPickerActivity extends ThemedInjectingAppCompatActivity { - public static final String EXTRA_TAGS = "extra_tags"; + public static final String EXTRA_SELECTED = "extra_tags"; + public static final String EXTRA_PARTIALLY_SELECTED = "extra_partial"; + public static final String EXTRA_TASKS = "extra_tasks"; @BindView(R.id.toolbar) Toolbar toolbar; @@ -41,14 +44,18 @@ public class TagPickerActivity extends ThemedInjectingAppCompatActivity { @Inject Inventory inventory; private TagPickerViewModel viewModel; + private ArrayList taskIds; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + Intent intent = getIntent(); + taskIds = (ArrayList) intent.getSerializableExtra(EXTRA_TASKS); if (savedInstanceState == null) { - ArrayList tags = getIntent().getParcelableArrayListExtra(EXTRA_TAGS); - viewModel.setTags(tags); + viewModel.setSelected( + intent.getParcelableArrayListExtra(EXTRA_SELECTED), + intent.getParcelableArrayListExtra(EXTRA_PARTIALLY_SELECTED)); } setContentView(R.layout.activity_tag_picker); @@ -73,16 +80,16 @@ public class TagPickerActivity extends ThemedInjectingAppCompatActivity { editText.setText(viewModel.getText()); } - private Void onToggle(TagData tagData, Boolean checked) { + private State onToggle(TagData tagData, Boolean checked) { boolean newTag = tagData.getId() == null; - viewModel.toggle(tagData, checked); + State state = viewModel.toggle(tagData, checked); if (newTag) { clear(); } - return null; + return state; } @OnTextChanged(R.id.search_input) @@ -94,7 +101,9 @@ public class TagPickerActivity extends ThemedInjectingAppCompatActivity { public void onBackPressed() { if (Strings.isNullOrEmpty(viewModel.getText())) { Intent data = new Intent(); - data.putParcelableArrayListExtra(EXTRA_TAGS, viewModel.getTags()); + data.putExtra(EXTRA_TASKS, taskIds); + data.putParcelableArrayListExtra(EXTRA_PARTIALLY_SELECTED, viewModel.getPartiallySelected()); + data.putParcelableArrayListExtra(EXTRA_SELECTED, viewModel.getSelected()); setResult(RESULT_OK, data); finish(); } else { diff --git a/app/src/main/java/org/tasks/tags/TagPickerViewHolder.java b/app/src/main/java/org/tasks/tags/TagPickerViewHolder.java index 3c448f9d3..d8de7dca9 100644 --- a/app/src/main/java/org/tasks/tags/TagPickerViewHolder.java +++ b/app/src/main/java/org/tasks/tags/TagPickerViewHolder.java @@ -5,7 +5,6 @@ import static com.todoroo.andlib.utility.AndroidUtilities.atLeastJellybeanMR1; import android.content.Context; import android.graphics.drawable.Drawable; import android.view.View; -import android.widget.CheckBox; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -19,22 +18,23 @@ import butterknife.OnClick; import kotlin.jvm.functions.Function2; import org.tasks.R; import org.tasks.data.TagData; +import org.tasks.tags.CheckBoxTriStates.State; public class TagPickerViewHolder extends RecyclerView.ViewHolder { private final Context context; - private final Function2 callback; + private final Function2 callback; @BindView(R.id.text) TextView text; @BindView(R.id.checkbox) - CheckBox checkBox; + CheckBoxTriStates checkBox; private TagData tagData; TagPickerViewHolder( - Context context, @NonNull View view, Function2 callback) { + Context context, @NonNull View view, Function2 callback) { super(view); this.callback = callback; @@ -54,10 +54,12 @@ public class TagPickerViewHolder extends RecyclerView.ViewHolder { @OnCheckedChanged(R.id.checkbox) void onCheckedChanged() { - callback.invoke(tagData, checkBox.isChecked()); + State newState = callback.invoke(tagData, checkBox.isChecked()); + updateCheckbox(newState); } - public void bind(@NonNull TagData tagData, int color, @Nullable Integer icon, boolean checked) { + public void bind( + @NonNull TagData tagData, int color, @Nullable Integer icon, State state) { this.tagData = tagData; if (tagData.getId() == null) { text.setText(context.getString(R.string.create_new_tag, tagData.getName())); @@ -65,8 +67,11 @@ public class TagPickerViewHolder extends RecyclerView.ViewHolder { checkBox.setVisibility(View.GONE); } else { text.setText(tagData.getName()); - checkBox.setChecked(checked); - checkBox.setVisibility(View.VISIBLE); + if (state == State.CHECKED) { + checkBox.setChecked(true); + } else { + updateCheckbox(state); + } if (icon == null) { icon = R.drawable.ic_outline_label_24px; } @@ -80,4 +85,9 @@ public class TagPickerViewHolder extends RecyclerView.ViewHolder { text.setCompoundDrawablesWithIntrinsicBounds(wrapped, null, null, null); } } + + private void updateCheckbox(State state) { + checkBox.setState(state, false); + checkBox.setVisibility(View.VISIBLE); + } } diff --git a/app/src/main/java/org/tasks/tags/TagPickerViewModel.java b/app/src/main/java/org/tasks/tags/TagPickerViewModel.java index a194d1c97..e89605373 100644 --- a/app/src/main/java/org/tasks/tags/TagPickerViewModel.java +++ b/app/src/main/java/org/tasks/tags/TagPickerViewModel.java @@ -3,6 +3,7 @@ package org.tasks.tags; import static com.google.common.collect.Iterables.any; import static com.google.common.collect.Lists.newArrayList; +import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; @@ -19,6 +20,7 @@ import java.util.Set; import javax.inject.Inject; import org.tasks.data.TagData; import org.tasks.data.TagDataDao; +import org.tasks.tags.CheckBoxTriStates.State; public class TagPickerViewModel extends ViewModel { @@ -27,21 +29,29 @@ public class TagPickerViewModel extends ViewModel { @Inject TagDataDao tagDataDao; + private final Set selected = new HashSet<>(); + private final Set partiallySelected = new HashSet<>(); private String text; - private Set selected = new HashSet<>(); public void observe(LifecycleOwner owner, Observer> observer) { tags.observe(owner, observer); } - public void setTags(List tags) { - selected.addAll(tags); + public void setSelected(List selected, @Nullable List partiallySelected) { + this.selected.addAll(selected); + if (partiallySelected != null) { + this.partiallySelected.addAll(partiallySelected); + } } - public ArrayList getTags() { + public ArrayList getSelected() { return newArrayList(selected); } + public ArrayList getPartiallySelected() { + return newArrayList(partiallySelected); + } + public String getText() { return text; } @@ -74,16 +84,27 @@ public class TagPickerViewModel extends ViewModel { return selected.contains(tagData); } - void toggle(TagData tagData, boolean checked) { + public State getState(TagData tagData) { + if (partiallySelected.contains(tagData)) { + return State.PARTIALLY_CHECKED; + } + return selected.contains(tagData) ? State.CHECKED : State.UNCHECKED; + } + + State toggle(TagData tagData, boolean checked) { if (tagData.getId() == null) { tagData = new TagData(tagData.getName()); tagDataDao.createNew(tagData); } + partiallySelected.remove(tagData); + if (checked) { selected.add(tagData); + return State.CHECKED; } else { selected.remove(tagData); + return State.UNCHECKED; } } } diff --git a/app/src/main/java/org/tasks/tags/TagRecyclerAdapter.java b/app/src/main/java/org/tasks/tags/TagRecyclerAdapter.java index 73a0ec581..e853aafda 100644 --- a/app/src/main/java/org/tasks/tags/TagRecyclerAdapter.java +++ b/app/src/main/java/org/tasks/tags/TagRecyclerAdapter.java @@ -13,6 +13,7 @@ import kotlin.jvm.functions.Function2; import org.tasks.R; import org.tasks.billing.Inventory; import org.tasks.data.TagData; +import org.tasks.tags.CheckBoxTriStates.State; import org.tasks.themes.CustomIcons; import org.tasks.themes.ThemeCache; @@ -23,14 +24,14 @@ public class TagRecyclerAdapter extends RecyclerView.Adapter callback; + private final Function2 callback; TagRecyclerAdapter( Context context, TagPickerViewModel viewModel, ThemeCache themeCache, Inventory inventory, - Function2 callback) { + Function2 callback) { this.context = context; this.viewModel = viewModel; this.themeCache = themeCache; @@ -49,8 +50,7 @@ public class TagRecyclerAdapter extends RecyclerView.Adapter + + diff --git a/app/src/main/res/layout/row_tag_picker.xml b/app/src/main/res/layout/row_tag_picker.xml index 79fdc4743..9568ad4e6 100644 --- a/app/src/main/res/layout/row_tag_picker.xml +++ b/app/src/main/res/layout/row_tag_picker.xml @@ -7,7 +7,7 @@ android:clickable="true" android:focusable="true"> - + +