diff --git a/app/src/main/java/com/todoroo/astrid/activity/BeastModePreferences.java b/app/src/main/java/com/todoroo/astrid/activity/BeastModePreferences.java index ec2c43cfe..de73d8358 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/BeastModePreferences.java +++ b/app/src/main/java/com/todoroo/astrid/activity/BeastModePreferences.java @@ -32,7 +32,7 @@ import org.tasks.ui.MenuColorizer; public class BeastModePreferences extends ThemedInjectingAppCompatActivity implements Toolbar.OnMenuItemClickListener { - private static final String BEAST_MODE_ORDER_PREF = "beast_mode_order_v5"; // $NON-NLS-1$ + private static final String BEAST_MODE_ORDER_PREF = "beast_mode_order_v6"; // $NON-NLS-1$ private static final String BEAST_MODE_PREF_ITEM_SEPARATOR = ";"; @BindView(R.id.toolbar) diff --git a/app/src/main/java/com/todoroo/astrid/activity/MainActivity.java b/app/src/main/java/com/todoroo/astrid/activity/MainActivity.java index dda7f0ecc..d32a2ca43 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/MainActivity.java +++ b/app/src/main/java/com/todoroo/astrid/activity/MainActivity.java @@ -61,12 +61,14 @@ import org.tasks.ui.DeadlineControlSet; import org.tasks.ui.EmptyTaskEditFragment; import org.tasks.ui.NavigationDrawerFragment; import org.tasks.ui.PriorityControlSet; +import org.tasks.ui.RemoteListFragment; import org.tasks.ui.TaskListViewModel; import org.tasks.ui.Toaster; public class MainActivity extends InjectingAppCompatActivity implements TaskListFragment.TaskListFragmentCallbackHandler, PriorityControlSet.OnPriorityChanged, + RemoteListFragment.OnListChanged, TimerControlSet.TimerControlSetCallback, DeadlineControlSet.DueDateChangeListener, TaskEditFragment.TaskEditFragmentCallbackHandler, @@ -511,4 +513,9 @@ public class MainActivity extends InjectingAppCompatActivity public void dueDateChanged(long dateTime) { getTaskEditFragment().onDueDateChanged(dateTime); } + + @Override + public void onListchanged(@Nullable Filter filter) { + getTaskEditFragment().onRemoteListChanged(filter); + } } diff --git a/app/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java b/app/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java index 6484502ee..89e49a1b1 100755 --- a/app/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java +++ b/app/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java @@ -19,6 +19,7 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; +import androidx.annotation.Nullable; import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentManager; @@ -27,6 +28,7 @@ import butterknife.BindView; import butterknife.ButterKnife; import com.todoroo.andlib.utility.AndroidUtilities; import com.todoroo.andlib.utility.DateUtilities; +import com.todoroo.astrid.api.Filter; import com.todoroo.astrid.dao.TaskDao; import com.todoroo.astrid.data.Task; import com.todoroo.astrid.notes.CommentsController; @@ -50,6 +52,7 @@ import org.tasks.injection.InjectingFragment; import org.tasks.notifications.NotificationManager; import org.tasks.preferences.Preferences; import org.tasks.ui.MenuColorizer; +import org.tasks.ui.SubtaskControlSet; import org.tasks.ui.TaskEditControlFragment; public final class TaskEditFragment extends InjectingFragment @@ -242,6 +245,10 @@ public final class TaskEditFragment extends InjectingFragment return getFragment(RepeatControlSet.TAG); } + private SubtaskControlSet getSubtaskControlSet() { + return getFragment(SubtaskControlSet.TAG); + } + @SuppressWarnings("unchecked") private T getFragment(int tag) { return (T) getChildFragmentManager().findFragmentByTag(getString(tag)); @@ -300,7 +307,7 @@ public final class TaskEditFragment extends InjectingFragment .show(); } - public void onPriorityChange(int priority) { + void onPriorityChange(int priority) { getEditTitleControlSet().setPriority(priority); } @@ -314,13 +321,20 @@ public final class TaskEditFragment extends InjectingFragment getEditTitleControlSet().repeatChanged(repeat); } - public void onDueDateChanged(long dueDate) { + void onDueDateChanged(long dueDate) { RepeatControlSet repeatControlSet = getRepeatControlSet(); if (repeatControlSet != null) { repeatControlSet.onDueDateChanged(dueDate); } } + void onRemoteListChanged(@Nullable Filter filter) { + SubtaskControlSet subtaskControlSet = getSubtaskControlSet(); + if (subtaskControlSet != null) { + subtaskControlSet.onRemoteListChanged(filter); + } + } + void addComment(String message, Uri picture) { UserActivity userActivity = new UserActivity(); if (picture != null) { diff --git a/app/src/main/java/org/tasks/data/CaldavDao.java b/app/src/main/java/org/tasks/data/CaldavDao.java index f58c4d586..6cfd78b14 100644 --- a/app/src/main/java/org/tasks/data/CaldavDao.java +++ b/app/src/main/java/org/tasks/data/CaldavDao.java @@ -72,6 +72,9 @@ public abstract class CaldavDao { @Query("SELECT * FROM caldav_tasks WHERE cd_task = :taskId AND cd_deleted = 0 LIMIT 1") public abstract CaldavTask getTask(long taskId); + @Query("SELECT cd_remote_id FROM caldav_tasks WHERE cd_task = :taskId AND cd_deleted = 0") + public abstract String getRemoteIdForTask(long taskId); + @Query("SELECT * FROM caldav_tasks WHERE cd_calendar = :calendar AND cd_object = :object LIMIT 1") public abstract CaldavTask getTask(String calendar, String object); diff --git a/app/src/main/java/org/tasks/fragments/TaskEditControlSetFragmentManager.java b/app/src/main/java/org/tasks/fragments/TaskEditControlSetFragmentManager.java index 55c662d79..8b60339b4 100644 --- a/app/src/main/java/org/tasks/fragments/TaskEditControlSetFragmentManager.java +++ b/app/src/main/java/org/tasks/fragments/TaskEditControlSetFragmentManager.java @@ -28,6 +28,7 @@ import org.tasks.ui.DescriptionControlSet; import org.tasks.ui.LocationControlSet; import org.tasks.ui.PriorityControlSet; import org.tasks.ui.RemoteListFragment; +import org.tasks.ui.SubtaskControlSet; import org.tasks.ui.TaskEditControlFragment; public class TaskEditControlSetFragmentManager { @@ -47,7 +48,8 @@ public class TaskEditControlSetFragmentManager { R.id.row_9, R.id.row_10, R.id.row_11, - R.id.row_12 + R.id.row_12, + R.id.row_13 }; private static final int[] TASK_EDIT_CONTROL_SET_FRAGMENTS = @@ -65,7 +67,8 @@ public class TaskEditControlSetFragmentManager { TagsControlSet.TAG, RepeatControlSet.TAG, CommentBarFragment.TAG, - RemoteListFragment.TAG + RemoteListFragment.TAG, + SubtaskControlSet.TAG }; static { @@ -160,6 +163,8 @@ public class TaskEditControlSetFragmentManager { return new CommentBarFragment(); case RemoteListFragment.TAG: return new RemoteListFragment(); + case SubtaskControlSet.TAG: + return new SubtaskControlSet(); default: throw new RuntimeException("Unsupported fragment"); } diff --git a/app/src/main/java/org/tasks/injection/FragmentComponent.java b/app/src/main/java/org/tasks/injection/FragmentComponent.java index c13ee11f4..43e0c339c 100644 --- a/app/src/main/java/org/tasks/injection/FragmentComponent.java +++ b/app/src/main/java/org/tasks/injection/FragmentComponent.java @@ -18,6 +18,8 @@ import org.tasks.ui.LocationControlSet; import org.tasks.ui.NavigationDrawerFragment; import org.tasks.ui.PriorityControlSet; import org.tasks.ui.RemoteListFragment; +import org.tasks.ui.SubtaskControlSet; +import org.tasks.ui.TaskListViewModel; @Subcomponent(modules = FragmentModule.class) public interface FragmentComponent { @@ -55,4 +57,8 @@ public interface FragmentComponent { void inject(RemoteListFragment remoteListFragment); void inject(LocationControlSet locationControlSet); + + void inject(SubtaskControlSet subtaskControlSet); + + void inject(TaskListViewModel taskListViewModel); } diff --git a/app/src/main/java/org/tasks/tasklist/SubtaskDiffCallback.java b/app/src/main/java/org/tasks/tasklist/SubtaskDiffCallback.java new file mode 100644 index 000000000..ec7f9e971 --- /dev/null +++ b/app/src/main/java/org/tasks/tasklist/SubtaskDiffCallback.java @@ -0,0 +1,19 @@ +package org.tasks.tasklist; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import org.tasks.data.TaskContainer; + +class SubtaskDiffCallback extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull TaskContainer oldItem, @NonNull TaskContainer newItem) { + return oldItem.getId() == newItem.getId(); + } + + @Override + public boolean areContentsTheSame( + @NonNull TaskContainer oldItem, @NonNull TaskContainer newItem) { + return oldItem.equals(newItem); + } +} diff --git a/app/src/main/java/org/tasks/tasklist/SubtaskViewHolder.java b/app/src/main/java/org/tasks/tasklist/SubtaskViewHolder.java new file mode 100644 index 000000000..85f4553c3 --- /dev/null +++ b/app/src/main/java/org/tasks/tasklist/SubtaskViewHolder.java @@ -0,0 +1,157 @@ +package org.tasks.tasklist; + +import static com.todoroo.andlib.utility.AndroidUtilities.atLeastLollipop; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.graphics.Paint; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.widget.TextView; +import androidx.recyclerview.widget.RecyclerView; +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; +import com.google.android.material.chip.Chip; +import com.todoroo.astrid.data.Task; +import com.todoroo.astrid.ui.CheckableImageView; +import org.tasks.R; +import org.tasks.data.TaskContainer; +import org.tasks.locale.Locale; +import org.tasks.ui.CheckBoxes; + +public class SubtaskViewHolder extends RecyclerView.ViewHolder { + + private final Activity context; + private final Locale locale; + private final Callbacks callbacks; + private final DisplayMetrics metrics; + + public TaskContainer task; + + @BindView(R.id.rowBody) + ViewGroup rowBody; + + @BindView(R.id.title) + TextView nameView; + + @BindView(R.id.completeBox) + CheckableImageView completeBox; + + @BindView(R.id.chip_button) + Chip chip; + + private int indent; + + SubtaskViewHolder( + Activity context, + Locale locale, + ViewGroup view, + Callbacks callbacks, + DisplayMetrics metrics) { + super(view); + this.context = context; + this.locale = locale; + this.callbacks = callbacks; + this.metrics = metrics; + ButterKnife.bind(this, view); + + view.setTag(this); + for (int i = 0; i < view.getChildCount(); i++) { + view.getChildAt(i).setTag(this); + } + } + + private float getShiftSize() { + return 20 * metrics.density; + } + + private int getIndentSize(int indent) { + return Math.round(indent * getShiftSize()); + } + + void bindView(TaskContainer task, boolean multiLevelSubtasks) { + this.task = task; + setIndent(multiLevelSubtasks ? task.indent : 0); + if (task.hasChildren()) { + chip.setText(locale.formatNumber(task.children)); + chip.setVisibility(View.VISIBLE); + chip.setChipIconResource( + task.isCollapsed() + ? R.drawable.ic_keyboard_arrow_up_black_24dp + : R.drawable.ic_keyboard_arrow_down_black_24dp); + } else { + chip.setVisibility(View.GONE); + } + + nameView.setText(task.getTitle()); + setupTitleAndCheckbox(); + } + + private void setupTitleAndCheckbox() { + if (task.isCompleted()) { + nameView.setEnabled(false); + nameView.setPaintFlags(nameView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); + } else { + nameView.setEnabled(!task.isHidden()); + nameView.setPaintFlags(nameView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); + } + + completeBox.setChecked(task.isCompleted()); + completeBox.setImageDrawable(CheckBoxes.getCheckBox(context, task.getTask())); + completeBox.invalidate(); + } + + @OnClick(R.id.title) + void openSubtask(View v) { + callbacks.openSubtask(task.getTask()); + } + + @OnClick(R.id.chip_button) + void toggleSubtasks(View v) { + callbacks.toggleSubtask(task.getId(), !task.isCollapsed()); + } + + @OnClick(R.id.completeBox) + void onCompleteBoxClick(View v) { + if (task == null) { + return; + } + + boolean newState = completeBox.isChecked(); + + if (newState != task.isCompleted()) { + callbacks.complete(task.getTask(), newState); + } + + // set check box to actual action item state + setupTitleAndCheckbox(); + } + + public int getIndent() { + return indent; + } + + @SuppressLint("NewApi") + public void setIndent(int indent) { + this.indent = indent; + int indentSize = getIndentSize(indent); + if (atLeastLollipop()) { + MarginLayoutParams layoutParams = (MarginLayoutParams) rowBody.getLayoutParams(); + layoutParams.setMarginStart(indentSize); + rowBody.setLayoutParams(layoutParams); + } else { + rowBody.setPadding(indentSize, rowBody.getPaddingTop(), 0, rowBody.getPaddingBottom()); + } + } + + public interface Callbacks { + void openSubtask(Task task); + + void toggleSubtask(long taskId, boolean collapsed); + + void complete(Task task, boolean completed); + } +} diff --git a/app/src/main/java/org/tasks/tasklist/SubtasksRecyclerAdapter.java b/app/src/main/java/org/tasks/tasklist/SubtasksRecyclerAdapter.java new file mode 100644 index 000000000..74b2f73ce --- /dev/null +++ b/app/src/main/java/org/tasks/tasklist/SubtasksRecyclerAdapter.java @@ -0,0 +1,93 @@ +package org.tasks.tasklist; + +import android.app.Activity; +import android.util.DisplayMetrics; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.ListUpdateCallback; +import androidx.recyclerview.widget.RecyclerView; +import java.util.List; +import org.tasks.R; +import org.tasks.data.TaskContainer; +import org.tasks.locale.Locale; +import org.tasks.tasklist.SubtaskViewHolder.Callbacks; + +public class SubtasksRecyclerAdapter extends RecyclerView.Adapter + implements ListUpdateCallback { + + private final DisplayMetrics metrics; + private final Activity activity; + private final Locale locale; + private final Callbacks callbacks; + private final AsyncListDiffer differ; + private boolean multiLevelSubtasks; + + public SubtasksRecyclerAdapter( + Activity activity, Locale locale, SubtaskViewHolder.Callbacks callbacks) { + this.activity = activity; + this.locale = locale; + this.callbacks = callbacks; + differ = + new AsyncListDiffer<>( + this, new AsyncDifferConfig.Builder<>(new SubtaskDiffCallback()).build()); + metrics = activity.getResources().getDisplayMetrics(); + } + + @NonNull + @Override + public SubtaskViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + ViewGroup view = + (ViewGroup) + LayoutInflater.from(activity).inflate(R.layout.subtask_adapter_row_body, parent, false); + return new SubtaskViewHolder(activity, locale, view, callbacks, metrics); + } + + @Override + public void onBindViewHolder(@NonNull SubtaskViewHolder holder, int position) { + TaskContainer task = getItem(position); + if (task != null) { + holder.bindView(task, multiLevelSubtasks); + } + } + + public TaskContainer getItem(int position) { + return differ.getCurrentList().get(position); + } + + public void submitList(List list) { + differ.submitList(list); + } + + @Override + public void onInserted(int position, int count) { + notifyItemRangeInserted(position, count); + } + + @Override + public void onRemoved(int position, int count) { + notifyDataSetChanged(); // remove animation is janky + } + + @Override + public void onMoved(int fromPosition, int toPosition) { + notifyItemMoved(fromPosition, toPosition); + } + + @Override + public void onChanged(int position, int count, @Nullable Object payload) { + notifyItemRangeChanged(position, count, payload); + } + + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + public void setMultiLevelSubtasksEnabled(boolean enabled) { + multiLevelSubtasks = enabled; + } +} diff --git a/app/src/main/java/org/tasks/ui/RemoteListFragment.java b/app/src/main/java/org/tasks/ui/RemoteListFragment.java index 738d9f37e..5819255d5 100644 --- a/app/src/main/java/org/tasks/ui/RemoteListFragment.java +++ b/app/src/main/java/org/tasks/ui/RemoteListFragment.java @@ -3,6 +3,7 @@ package org.tasks.ui; import static android.app.Activity.RESULT_OK; import static org.tasks.activities.RemoteListSupportPicker.newRemoteListSupportPicker; +import android.app.Activity; import android.content.Intent; import android.os.Bundle; import android.view.LayoutInflater; @@ -55,6 +56,18 @@ public class RemoteListFragment extends TaskEditControlFragment { @Nullable private Filter originalList; @Nullable private Filter selectedList; + private OnListChanged callback; + + public interface OnListChanged { + void onListchanged(@Nullable Filter filter); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + callback = (OnListChanged) activity; + } @Nullable @Override @@ -63,7 +76,7 @@ public class RemoteListFragment extends TaskEditControlFragment { View view = super.onCreateView(inflater, container, savedInstanceState); if (savedInstanceState != null) { originalList = savedInstanceState.getParcelable(EXTRA_ORIGINAL_LIST); - selectedList = savedInstanceState.getParcelable(EXTRA_SELECTED_LIST); + setSelected(savedInstanceState.getParcelable(EXTRA_SELECTED_LIST)); } else { if (task.isNew()) { if (task.hasTransitory(GoogleTask.KEY)) { @@ -96,18 +109,18 @@ public class RemoteListFragment extends TaskEditControlFragment { } } - selectedList = originalList; + setSelected(originalList); } - chip.setOnCloseIconClickListener(this::clearSelected); + chip.setOnCloseIconClickListener(v -> setSelected(null)); - refreshView(); return view; } - private void clearSelected(View ignored) { - selectedList = null; + private void setSelected(@Nullable Filter filter) { + selectedList = filter; refreshView(); + callback.onListchanged(filter); } @Override @@ -171,13 +184,12 @@ public class RemoteListFragment extends TaskEditControlFragment { private void setList(Filter list) { if (list == null) { - this.selectedList = null; + setSelected(null); } else if (list instanceof GtasksFilter || list instanceof CaldavFilter) { - this.selectedList = list; + setSelected(list); } else { throw new RuntimeException("Unhandled filter type"); } - refreshView(); } private void refreshView() { diff --git a/app/src/main/java/org/tasks/ui/SubtaskControlSet.java b/app/src/main/java/org/tasks/ui/SubtaskControlSet.java new file mode 100644 index 000000000..e8af66fcc --- /dev/null +++ b/app/src/main/java/org/tasks/ui/SubtaskControlSet.java @@ -0,0 +1,336 @@ +package org.tasks.ui; + +import static com.todoroo.andlib.utility.DateUtilities.now; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.graphics.Paint; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import butterknife.BindView; +import butterknife.OnClick; +import com.google.common.base.Strings; +import com.todoroo.andlib.sql.Criterion; +import com.todoroo.andlib.sql.Join; +import com.todoroo.andlib.sql.QueryTemplate; +import com.todoroo.astrid.activity.MainActivity; +import com.todoroo.astrid.api.CaldavFilter; +import com.todoroo.astrid.api.Filter; +import com.todoroo.astrid.api.GtasksFilter; +import com.todoroo.astrid.dao.TaskDao; +import com.todoroo.astrid.dao.TaskDao.TaskCriteria; +import com.todoroo.astrid.data.Task; +import com.todoroo.astrid.service.TaskCompleter; +import com.todoroo.astrid.service.TaskCreator; +import com.todoroo.astrid.ui.CheckableImageView; +import java.util.ArrayList; +import javax.inject.Inject; +import org.tasks.LocalBroadcastManager; +import org.tasks.R; +import org.tasks.data.CaldavDao; +import org.tasks.data.CaldavTask; +import org.tasks.data.GoogleTask; +import org.tasks.data.GoogleTaskDao; +import org.tasks.injection.FragmentComponent; +import org.tasks.locale.Locale; +import org.tasks.preferences.Preferences; +import org.tasks.tasklist.SubtaskViewHolder.Callbacks; +import org.tasks.tasklist.SubtasksRecyclerAdapter; + +public class SubtaskControlSet extends TaskEditControlFragment implements Callbacks { + + public static final int TAG = R.string.TEA_ctrl_subtask_pref; + private static final String EXTRA_NEW_SUBTASKS = "extra_new_subtasks"; + + @BindView(R.id.recycler_view) + RecyclerView recyclerView; + + @BindView(R.id.add_subtask) + TextView addSubtask; + + @BindView(R.id.new_subtasks) + LinearLayout newSubtaskContainer; + + @Inject Activity activity; + @Inject TaskCompleter taskCompleter; + @Inject LocalBroadcastManager localBroadcastManager; + @Inject GoogleTaskDao googleTaskDao; + @Inject Toaster toaster; + @Inject Preferences preferences; + @Inject TaskCreator taskCreator; + @Inject CaldavDao caldavDao; + @Inject TaskDao taskDao; + @Inject Locale locale; + + private TaskListViewModel viewModel; + private final RefreshReceiver refreshReceiver = new RefreshReceiver(); + private Filter remoteList; + private GoogleTask googleTask; + private SubtasksRecyclerAdapter recyclerAdapter; + + @Override + protected void inject(FragmentComponent component) { + component.inject(this); + viewModel = ViewModelProviders.of(this).get(TaskListViewModel.class); + component.inject(viewModel); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putParcelableArrayList(EXTRA_NEW_SUBTASKS, getNewSubtasks()); + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + final View view = super.onCreateView(inflater, container, savedInstanceState); + + if (savedInstanceState != null) { + for (Task task : savedInstanceState.getParcelableArrayList(EXTRA_NEW_SUBTASKS)) { + addSubtask(task); + } + } + + recyclerAdapter = new SubtasksRecyclerAdapter(activity, locale, this); + if (task.getId() > 0) { + recyclerAdapter.submitList(viewModel.getValue()); + viewModel.setFilter(new Filter("subtasks", getQueryTemplate(task)), true); + ((DefaultItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); + recyclerView.setLayoutManager(new LinearLayoutManager(activity)); + recyclerView.setNestedScrollingEnabled(false); + viewModel.observe(this, recyclerAdapter::submitList); + recyclerView.setAdapter(recyclerAdapter); + } + return view; + } + + private static QueryTemplate getQueryTemplate(Task task) { + return new QueryTemplate() + .join( + Join.left( + GoogleTask.TABLE, + Criterion.and( + GoogleTask.PARENT.eq(task.getId()), + GoogleTask.TASK.eq(Task.ID), + GoogleTask.DELETED.eq(0)))) + .join( + Join.left( + CaldavTask.TABLE, + Criterion.and( + CaldavTask.PARENT.eq(task.getId()), + CaldavTask.TASK.eq(Task.ID), + CaldavTask.DELETED.eq(0)))) + .where(Criterion.and(TaskCriteria.activeAndVisible(), + Criterion.or(CaldavTask.TASK.gt(0), GoogleTask.TASK.gt(0)))); + + } + + @Override + protected int getLayout() { + return R.layout.control_set_subtasks; + } + + @Override + protected int getIcon() { + return R.drawable.ic_subdirectory_arrow_right_black_24dp; + } + + @Override + public int controlId() { + return TAG; + } + + @Override + public void apply(Task task) { + for (Task subtask: getNewSubtasks()) { + if (Strings.isNullOrEmpty(subtask.getTitle())) { + continue; + } + taskDao.createNew(subtask); + if (remoteList instanceof GtasksFilter) { + GoogleTask googleTask = + new GoogleTask(subtask.getId(), ((GtasksFilter) remoteList).getRemoteId()); + googleTask.setParent(task.getId()); + googleTask.setMoved(true); + googleTaskDao.insertAndShift(googleTask, preferences.addGoogleTasksToTop()); + } else if (remoteList instanceof CaldavFilter) { + CaldavTask caldavTask = + new CaldavTask(subtask.getId(), ((CaldavFilter) remoteList).getUuid()); + caldavTask.setParent(task.getId()); + caldavTask.setRemoteParent(caldavDao.getRemoteIdForTask(task.getId())); + caldavDao.insert(caldavTask); + } + } + } + + @Override + public boolean hasChanges(Task original) { + return remoteList != null && !getNewSubtasks().isEmpty(); + } + + private ArrayList getNewSubtasks() { + ArrayList subtasks = new ArrayList<>(); + int children = newSubtaskContainer.getChildCount(); + for (int i = 0 ; i < children ; i++) { + View view = newSubtaskContainer.getChildAt(i); + EditText title = view.findViewById(R.id.title); + CheckableImageView completed = view.findViewById(R.id.completeBox); + Task subtask = taskCreator.createWithValues(title.getText().toString()); + if (completed.isChecked()) { + subtask.setCompletionDate(now()); + } + subtasks.add(subtask); + } + return subtasks; + } + + @Override + public void onResume() { + super.onResume(); + + localBroadcastManager.registerRefreshReceiver(refreshReceiver); + + googleTask = googleTaskDao.getByTaskId(task.getId()); + + updateUI(); + } + + @Override + public void onPause() { + super.onPause(); + + localBroadcastManager.unregisterReceiver(refreshReceiver); + } + + @OnClick(R.id.add_subtask) + void addSubtask() { + if (remoteList == null) { + toaster.longToast(R.string.subtasks_enable_synchronization); + return; + } + if (isGoogleTaskChild()) { + toaster.longToast(R.string.subtasks_multilevel_google_task); + return; + } + EditText editText = addSubtask(taskCreator.createWithValues("")); + editText.requestFocus(); + InputMethodManager imm = + (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT); + } + + private EditText addSubtask(Task task) { + ViewGroup view = + (ViewGroup) + LayoutInflater.from(activity) + .inflate(R.layout.editable_subtask_adapter_row_body, newSubtaskContainer, false); + view.findViewById(R.id.clear).setOnClickListener(v -> newSubtaskContainer.removeView(view)); + EditText editText = view.findViewById(R.id.title); + editText.setTextKeepState(task.getTitle()); + editText.setHorizontallyScrolling(false); + editText.setLines(1); + editText.setMaxLines(Integer.MAX_VALUE); + editText.setFocusable(true); + editText.setEnabled(true); + editText.setOnEditorActionListener( + (arg0, actionId, arg2) -> { + if (actionId == EditorInfo.IME_ACTION_NEXT) { + if (editText.getText().length() != 0) { + addSubtask(); + } + return true; + } + return false; + }); + + CheckableImageView completeBox = view.findViewById(R.id.completeBox); + completeBox.setChecked(task.isCompleted()); + updateCompleteBox(task, completeBox, editText); + completeBox.setOnClickListener(v -> updateCompleteBox(task, completeBox, editText)); + newSubtaskContainer.addView(view); + return editText; + } + + private void updateCompleteBox(Task task, CheckableImageView completeBox, EditText editText) { + boolean isComplete = completeBox.isChecked(); + completeBox.setImageDrawable( + CheckBoxes.getCheckBox(activity, isComplete, false, task.getPriority())); + if (isComplete) { + editText.setPaintFlags(editText.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); + } else { + editText.setPaintFlags(editText.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); + } + } + + private boolean isGoogleTaskChild() { + return remoteList instanceof GtasksFilter + && googleTask != null + && googleTask.getParent() > 0 + && googleTask.getListId().equals(((GtasksFilter) remoteList).getRemoteId()); + } + + private void updateUI() { + if (remoteList == null || isGoogleTaskChild()) { + recyclerView.setVisibility(View.GONE); + newSubtaskContainer.setVisibility(View.GONE); + } else { + recyclerView.setVisibility(View.VISIBLE); + newSubtaskContainer.setVisibility(View.VISIBLE); + recyclerAdapter.setMultiLevelSubtasksEnabled(!(remoteList instanceof GtasksFilter)); + refresh(); + } + } + + public void onRemoteListChanged(@Nullable Filter filter) { + this.remoteList = filter; + + if (recyclerView != null) { + updateUI(); + } + } + + private void refresh() { + viewModel.invalidate(); + } + + @Override + public void openSubtask(Task task) { + ((MainActivity) getActivity()).getTaskListFragment().onTaskListItemClicked(task); + } + + @Override + public void toggleSubtask(long taskId, boolean collapsed) { + taskDao.setCollapsed(taskId, collapsed); + localBroadcastManager.broadcastRefresh(); + } + + @Override + public void complete(Task task, boolean completed) { + taskCompleter.setComplete(task, completed); + } + + protected class RefreshReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + refresh(); + } + } +} diff --git a/app/src/main/java/org/tasks/ui/TaskListViewModel.java b/app/src/main/java/org/tasks/ui/TaskListViewModel.java index 21c22f220..9bc489c9a 100644 --- a/app/src/main/java/org/tasks/ui/TaskListViewModel.java +++ b/app/src/main/java/org/tasks/ui/TaskListViewModel.java @@ -272,9 +272,14 @@ public class TaskListViewModel extends ViewModel { } public void invalidate() { + if (filter == null) { + return; + } + disposable.add( Single.fromCallable( - () -> taskDao.fetchTasks(hasSubtasks -> getQuery(preferences, filter, hasSubtasks))) + () -> + taskDao.fetchTasks(hasSubtasks -> getQuery(preferences, filter, hasSubtasks))) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(tasks::postValue, Timber::e)); diff --git a/app/src/main/res/drawable/ic_subdirectory_arrow_right_black_24dp.xml b/app/src/main/res/drawable/ic_subdirectory_arrow_right_black_24dp.xml new file mode 100644 index 000000000..e8b2e1681 --- /dev/null +++ b/app/src/main/res/drawable/ic_subdirectory_arrow_right_black_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/control_set_subtasks.xml b/app/src/main/res/layout/control_set_subtasks.xml new file mode 100644 index 000000000..f301a631a --- /dev/null +++ b/app/src/main/res/layout/control_set_subtasks.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/editable_subtask_adapter_row_body.xml b/app/src/main/res/layout/editable_subtask_adapter_row_body.xml new file mode 100644 index 000000000..2edbbf11c --- /dev/null +++ b/app/src/main/res/layout/editable_subtask_adapter_row_body.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_task_edit.xml b/app/src/main/res/layout/fragment_task_edit.xml index 55a49e6d7..c8a6ed08b 100644 --- a/app/src/main/res/layout/fragment_task_edit.xml +++ b/app/src/main/res/layout/fragment_task_edit.xml @@ -10,7 +10,7 @@ - + + - + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 73d9001fa..1d3351aa1 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -162,6 +162,7 @@ @string/TEA_control_location @string/tags @string/synchronization + @string/subtasks @string/TEA_control_reminders @string/TEA_control_files @string/TEA_control_notes @@ -175,6 +176,7 @@ TEA_ctrl_when_pref TEA_ctrl_repeat_pref TEA_ctrl_importance_pref + TEA_ctrl_subtask_pref TEA_ctrl_lists_pref TEA_ctrl_notes_pref TEA_ctrl_files_pref @@ -199,6 +201,7 @@ @string/TEA_ctrl_locations_pref @string/TEA_ctrl_lists_pref @string/TEA_ctrl_google_task_list + @string/TEA_ctrl_subtask_pref @string/TEA_ctrl_reminders_pref @string/TEA_ctrl_files_pref @string/TEA_ctrl_notes_pref diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b527b2891..0209120b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,7 @@ File %1$s contained %2$s.\n\n Create new filter Task name Priority + Add subtask No date Hide until Hide until %s @@ -318,6 +319,7 @@ File %1$s contained %2$s.\n\n Copy to Google Drive Miscellaneous Synchronization + Subtasks Enabled Font size Row spacing @@ -556,4 +558,7 @@ File %1$s contained %2$s.\n\n Let server schedule recurring tasks Expand subtasks Collapse subtasks + Enable synchronization to add subtasks + Multi-level subtasks not supported by Google Tasks + Enter title