From ea88dc0396bbe1da2907519eb2a4e6167f4cacdd Mon Sep 17 00:00:00 2001 From: Marten Gajda Date: Mon, 8 Mar 2021 21:39:38 +0100 Subject: [PATCH] Add simple recurrence picker. Implements #993 (#1007) Add a simple popup recurrence picker offering some common recurrence patterns. Since there is no way to set an end to recurring tasks yet, this commit also ensures they can at least be deleted. --- .../org/dmfs/tasks/contract/TaskContract.java | 9 + .../provider/tasks/TaskDatabaseHelper.java | 10 +- opentasks/build.gradle | 1 + .../java/org/dmfs/tasks/EditTaskFragment.java | 5 +- .../java/org/dmfs/tasks/ViewTaskFragment.java | 148 ++++++++--- .../org/dmfs/tasks/model/DefaultModel.java | 5 + .../main/java/org/dmfs/tasks/model/Model.java | 26 +- .../dmfs/tasks/model/TaskFieldAdapters.java | 31 +++ .../java/org/dmfs/tasks/model/XmlModel.java | 13 + .../model/adapters/DateTimeFieldAdapter.java | 250 ++++++++++++++++++ .../adapters/OptionalLongFieldAdapter.java | 138 ++++++++++ .../model/adapters/RRuleFieldAdapter.java | 149 +++++++++++ .../dmfs/tasks/widget/RRuleFieldEditor.java | 205 ++++++++++++++ .../tasks/widget/recurrence/Conditional.java | 68 +++++ .../tasks/widget/recurrence/NotRepeating.java | 51 ++++ .../recurrence/RecurrencePopupGenerator.java | 53 ++++ .../tasks/widget/recurrence/RepeatByRule.java | 56 ++++ .../play/release-notes/en-GB/production.txt | 2 +- .../res/drawable/ic_baseline_repeat_24.xml | 26 ++ .../layout/opentasks_rrule_field_editor.xml | 14 + opentasks/src/main/res/values/ids.xml | 2 + opentasks/src/main/res/values/strings.xml | 8 + 22 files changed, 1221 insertions(+), 49 deletions(-) create mode 100644 opentasks/src/main/java/org/dmfs/tasks/model/adapters/DateTimeFieldAdapter.java create mode 100644 opentasks/src/main/java/org/dmfs/tasks/model/adapters/OptionalLongFieldAdapter.java create mode 100644 opentasks/src/main/java/org/dmfs/tasks/model/adapters/RRuleFieldAdapter.java create mode 100644 opentasks/src/main/java/org/dmfs/tasks/widget/RRuleFieldEditor.java create mode 100644 opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/Conditional.java create mode 100644 opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/NotRepeating.java create mode 100644 opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/RecurrencePopupGenerator.java create mode 100644 opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/RepeatByRule.java create mode 100644 opentasks/src/main/res/drawable/ic_baseline_repeat_24.xml create mode 100644 opentasks/src/main/res/layout/opentasks_rrule_field_editor.xml diff --git a/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java b/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java index 77392ca64..3ce329daa 100644 --- a/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java +++ b/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java @@ -1211,6 +1211,15 @@ public static final class Instances implements TaskColumns, InstanceColumns */ public static final String VISIBLE = "visible"; + /** + * Flag indicating that ths is an instance of a recurring task. + *

+ * Value: Integer + *

+ * read-only + */ + public static final String IS_RECURRING = "is_recurring"; + public static final String CONTENT_URI_PATH = "instances"; public static final String DEFAULT_SORT_ORDER = INSTANCE_DUE_SORTING; diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java index fcdeebc10..a78d8c76d 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java @@ -71,7 +71,7 @@ public interface OnDatabaseOperationListener /** * The database version. */ - private static final int DATABASE_VERSION = 22; + private static final int DATABASE_VERSION = 23; /** @@ -197,6 +197,8 @@ public interface CategoriesMapping + "null as " + Tasks.RRULE + ", " + "null as " + Tasks.RDATE + ", " + "null as " + Tasks.EXDATE + ", " + // this instance is part of a recurring task if either it has recurrence values or overrides an instance + + "not (" + Tasks.RRULE + " is null and " + Tasks.RDATE + " is null and " + Tasks.ORIGINAL_INSTANCE_ID + " is null and " + Tasks.ORIGINAL_INSTANCE_SYNC_ID + " is null) as " + TaskContract.Instances.IS_RECURRING + ", " + Tables.TASKS + ".*, " + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " @@ -875,6 +877,12 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) } } + if (oldVersion < 23) + { + db.execSQL("drop view " + Tables.INSTANCE_CLIENT_VIEW + ";"); + db.execSQL(SQL_CREATE_INSTANCE_CLIENT_VIEW); + } + // upgrade FTS FTSDatabaseHelper.onUpgrade(db, oldVersion, newVersion); diff --git a/opentasks/build.gradle b/opentasks/build.gradle index 0786c16f7..da62e8345 100644 --- a/opentasks/build.gradle +++ b/opentasks/build.gradle @@ -100,6 +100,7 @@ dependencies { implementation 'io.reactivex.rxjava2:rxjava:2.2.21' implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' implementation 'androidx.preference:preference:1.1.1' + implementation 'com.maltaisn:recurpicker:2.1.4' } if (project.hasProperty('PLAY_STORE_SERVICE_ACCOUNT_CREDENTIALS')) { diff --git a/opentasks/src/main/java/org/dmfs/tasks/EditTaskFragment.java b/opentasks/src/main/java/org/dmfs/tasks/EditTaskFragment.java index 2eb3cda7b..27cd846e5 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/EditTaskFragment.java +++ b/opentasks/src/main/java/org/dmfs/tasks/EditTaskFragment.java @@ -141,9 +141,10 @@ private interface TASK_LIST_PROJECTION_VALUES static final ContentValueMapper CONTENT_VALUE_MAPPER = new ContentValueMapper() .addString(Tasks.ACCOUNT_TYPE, Tasks.ACCOUNT_NAME, Tasks.TITLE, Tasks.LOCATION, Tasks.DESCRIPTION, Tasks.GEO, Tasks.URL, Tasks.TZ, Tasks.DURATION, - Tasks.LIST_NAME) + Tasks.LIST_NAME, Tasks.RRULE, Tasks.RDATE) .addInteger(Tasks.PRIORITY, Tasks.LIST_COLOR, Tasks.TASK_COLOR, Tasks.STATUS, Tasks.CLASSIFICATION, Tasks.PERCENT_COMPLETE, Tasks.IS_ALLDAY, - Tasks.IS_CLOSED, Tasks.PINNED).addLong(Tasks.LIST_ID, Tasks.DTSTART, Tasks.DUE, Tasks.COMPLETED, Tasks._ID); + Tasks.IS_CLOSED, Tasks.PINNED) + .addLong(Tasks.LIST_ID, Tasks.DTSTART, Tasks.DUE, Tasks.COMPLETED, Tasks._ID, Tasks.ORIGINAL_INSTANCE_ID); private boolean mAppForEdit = true; private TasksListCursorSpinnerAdapter mTaskListAdapter; diff --git a/opentasks/src/main/java/org/dmfs/tasks/ViewTaskFragment.java b/opentasks/src/main/java/org/dmfs/tasks/ViewTaskFragment.java index 8b3539c79..e65b2b694 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/ViewTaskFragment.java +++ b/opentasks/src/main/java/org/dmfs/tasks/ViewTaskFragment.java @@ -21,27 +21,15 @@ import android.app.AlertDialog; import android.content.ContentValues; import android.content.Context; -import android.content.DialogInterface; -import android.content.DialogInterface.OnClickListener; import android.content.Intent; +import android.content.OperationApplicationException; import android.content.res.ColorStateList; import android.database.ContentObserver; import android.net.Uri; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.android.material.appbar.AppBarLayout; -import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.snackbar.Snackbar; -import androidx.core.app.ActivityCompat; -import androidx.core.view.MenuItemCompat; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.ShareActionProvider; -import androidx.appcompat.widget.Toolbar; -import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener; +import android.os.RemoteException; import android.text.TextUtils; +import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -51,11 +39,28 @@ import android.view.animation.AlphaAnimation; import android.widget.TextView; +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.snackbar.Snackbar; + import org.dmfs.android.bolts.color.Color; import org.dmfs.android.bolts.color.elementary.ValueColor; +import org.dmfs.android.contentpal.Operation; +import org.dmfs.android.contentpal.operations.BulkDelete; +import org.dmfs.android.contentpal.predicates.AnyOf; +import org.dmfs.android.contentpal.predicates.EqArg; +import org.dmfs.android.contentpal.predicates.IdIn; +import org.dmfs.android.contentpal.transactions.BaseTransaction; import org.dmfs.android.retentionmagic.SupportFragment; import org.dmfs.android.retentionmagic.annotations.Parameter; import org.dmfs.android.retentionmagic.annotations.Retain; +import org.dmfs.jems.iterable.adapters.PresentValues; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.opentaskspal.tables.InstanceTable; +import org.dmfs.opentaskspal.tables.TasksTable; +import org.dmfs.tasks.contract.TaskContract; import org.dmfs.tasks.contract.TaskContract.Tasks; import org.dmfs.tasks.model.ContentSet; import org.dmfs.tasks.model.Model; @@ -70,9 +75,17 @@ import org.dmfs.tasks.utils.colors.AdjustedForFab; import org.dmfs.tasks.widget.TaskView; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.ShareActionProvider; +import androidx.appcompat.widget.Toolbar; +import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.app.ActivityCompat; +import androidx.core.view.MenuItemCompat; /** @@ -88,16 +101,16 @@ public class ViewTaskFragment extends SupportFragment private final static String ARG_URI = "uri"; private static final String ARG_STARTING_COLOR = "starting_color"; - /** - * A set of values that may affect the recurrence set of a task. If one of these values changes we have to submit all of them. - */ - private final static Set RECURRENCE_VALUES = new HashSet( - Arrays.asList(Tasks.DUE, Tasks.DTSTART, Tasks.TZ, Tasks.IS_ALLDAY, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE)); - /** * The {@link ContentValueMapper} that knows how to map the values in a cursor to {@link ContentValues}. */ - private static final ContentValueMapper CONTENT_VALUE_MAPPER = EditTaskFragment.CONTENT_VALUE_MAPPER; + + private static final ContentValueMapper CONTENT_VALUE_MAPPER = new ContentValueMapper() + .addString(Tasks.ACCOUNT_TYPE, Tasks.ACCOUNT_NAME, Tasks.TITLE, Tasks.LOCATION, Tasks.DESCRIPTION, Tasks.GEO, Tasks.URL, Tasks.TZ, Tasks.DURATION, + Tasks.LIST_NAME, Tasks.RRULE, Tasks.RDATE) + .addInteger(Tasks.PRIORITY, Tasks.LIST_COLOR, Tasks.TASK_COLOR, Tasks.STATUS, Tasks.CLASSIFICATION, Tasks.PERCENT_COMPLETE, Tasks.IS_ALLDAY, + Tasks.IS_CLOSED, Tasks.PINNED, TaskContract.Instances.IS_RECURRING) + .addLong(Tasks.LIST_ID, Tasks.DTSTART, Tasks.DUE, Tasks.COMPLETED, Tasks._ID, Tasks.ORIGINAL_INSTANCE_ID, TaskContract.Instances.TASK_ID); private static final float PERCENTAGE_TO_HIDE_TITLE_DETAILS = 0.3f; private static final int ALPHA_ANIMATIONS_DURATION = 200; @@ -550,36 +563,80 @@ public boolean onOptionsItemSelected(MenuItem item) } else if (itemId == R.id.delete_task) { - new AlertDialog.Builder(getActivity()).setTitle(R.string.confirm_delete_title).setCancelable(true) - .setNegativeButton(android.R.string.cancel, new OnClickListener() - { - @Override - public void onClick(DialogInterface dialog, int which) + long originalInstanceId = new Backed<>(TaskFieldAdapters.ORIGINAL_INSTANCE_ID.get(mContentSet), () -> + Long.valueOf(TaskFieldAdapters.INSTANCE_TASK_ID.get(mContentSet))).value(); + boolean isRecurring = TaskFieldAdapters.IS_RECURRING_INSTANCE.get(mContentSet); + AtomicReference> operation = new AtomicReference<>( + new BulkDelete<>( + new InstanceTable(mTaskUri.getAuthority()), + new IdIn<>(mTaskUri.getLastPathSegment()))); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + // nothing to do here + }) + .setTitle(isRecurring ? R.string.opentasks_task_details_delete_recurring_task : R.string.confirm_delete_title) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + if (mContentSet != null) { - // nothing to do here + // TODO: remove the task in a background task + try + { + new BaseTransaction() + .with(new PresentValues<>(new NullSafe<>(operation.get()))) + .commit(getContext().getContentResolver().acquireContentProviderClient(mTaskUri)); + } + catch (RemoteException | OperationApplicationException e) + { + Log.e(ViewTaskFragment.class.getSimpleName(), "Unable to delete task ", e); + } + + mCallback.onTaskDeleted(mTaskUri); + mTaskUri = null; } - }).setPositiveButton(android.R.string.ok, new OnClickListener() + }); + if (isRecurring) { - @Override - public void onClick(DialogInterface dialog, int which) - { - if (mContentSet != null) - { - // TODO: remove the task in a background task - mContentSet.delete(mAppContext); - mCallback.onTaskDeleted(mTaskUri); - mTaskUri = null; - } - } - }).setMessage(R.string.confirm_delete_message).create().show(); + builder.setSingleChoiceItems( + new CharSequence[] { + getString(R.string.opentasks_task_details_delete_this_task), + getString(R.string.opentasks_task_details_delete_all_tasks) + }, + 0, + (dialog, which) -> { + switch (which) + { + case 0: + operation.set(new BulkDelete<>( + new InstanceTable(mTaskUri.getAuthority()), + new IdIn<>(mTaskUri.getLastPathSegment()))); + case 1: + operation.set(new BulkDelete<>( + new TasksTable(mTaskUri.getAuthority()), + new AnyOf<>( + new IdIn<>(originalInstanceId), + new EqArg<>(Tasks.ORIGINAL_INSTANCE_ID, originalInstanceId)))); + + } + }); + } + else + { + builder.setMessage(R.string.confirm_delete_message); + } + builder.create().show(); + return true; + } else if (itemId == R.id.complete_task) + { completeTask(); return true; } else if (itemId == R.id.pin_task) + { if (TaskFieldAdapters.PINNED.get(mContentSet)) { @@ -595,14 +652,17 @@ else if (itemId == R.id.pin_task) return true; } else if (itemId == R.id.opentasks_send_task) + { setSendMenuIntent(); return false; } else + { return super.onOptionsItemSelected(item); } + } diff --git a/opentasks/src/main/java/org/dmfs/tasks/model/DefaultModel.java b/opentasks/src/main/java/org/dmfs/tasks/model/DefaultModel.java index 23cccf4e3..e480dcaf3 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/model/DefaultModel.java +++ b/opentasks/src/main/java/org/dmfs/tasks/model/DefaultModel.java @@ -53,6 +53,7 @@ public class DefaultModel extends Model private final static LayoutDescriptor BOOLEAN_EDIT = new LayoutDescriptor(R.layout.boolean_field_editor); private final static LayoutDescriptor URL_VIEW = new LayoutDescriptor(R.layout.url_field_view); private final static LayoutDescriptor URL_EDIT = new LayoutDescriptor(R.layout.url_field_editor); + private final static LayoutDescriptor RRULE_EDIT = new LayoutDescriptor(R.layout.opentasks_rrule_field_editor); final static LayoutDescriptor LIST_COLOR_VIEW = new LayoutDescriptor(R.layout.list_color_view); @@ -122,6 +123,10 @@ public void inflate() // all day flag addField(new FieldDescriptor(context, R.id.task_field_all_day, R.string.task_all_day, TaskFieldAdapters.ALLDAY).setEditorLayout(BOOLEAN_EDIT)); + // rrule + addField(new FieldDescriptor(context, R.id.task_field_rrule, R.string.task_recurrence, TaskFieldAdapters.RRULE) + .setEditorLayout(RRULE_EDIT).setIcon(R.drawable.ic_baseline_repeat_24)); + TimeZoneChoicesAdapter tzaca = new TimeZoneChoicesAdapter(context); // time zone addField(new FieldDescriptor(context, R.id.task_field_timezone, R.string.task_timezone, TaskFieldAdapters.TIMEZONE).setEditorLayout(CHOICES_EDIT) diff --git a/opentasks/src/main/java/org/dmfs/tasks/model/Model.java b/opentasks/src/main/java/org/dmfs/tasks/model/Model.java index 2899c85c2..889b8b021 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/model/Model.java +++ b/opentasks/src/main/java/org/dmfs/tasks/model/Model.java @@ -22,16 +22,22 @@ import android.content.ContentUris; import android.content.Context; import android.content.Intent; -import androidx.collection.SparseArrayCompat; import android.text.TextUtils; +import org.dmfs.iterables.decorators.Sieved; +import org.dmfs.jems.optional.adapters.First; +import org.dmfs.jems.single.combined.Backed; import org.dmfs.provider.tasks.AuthorityUtil; +import org.dmfs.provider.tasks.utils.Range; import org.dmfs.tasks.ManageListActivity; import org.dmfs.tasks.contract.TaskContract.TaskLists; import java.util.ArrayList; import java.util.List; +import androidx.annotation.IdRes; +import androidx.collection.SparseArrayCompat; + /** * An abstract model class. @@ -95,6 +101,24 @@ protected void addField(FieldDescriptor descriptor) } + /** + * Adds another field (identified by its field descriptor) to this model. + * + * @param descriptor + * The {@link FieldDescriptor} of the field to add. + */ + protected void addFieldAfter(@IdRes int id, FieldDescriptor descriptor) + { + mFields.add( + new Backed<>( + new First<>( + new Sieved<>(i -> mFields.get(i).getFieldId() == id, + new Range(0, mFields.size()))), mFields::size).value(), + descriptor); + mFieldIndex.put(descriptor.getFieldId(), descriptor); + } + + public FieldDescriptor getField(int fieldId) { return mFieldIndex.get(fieldId, null); diff --git a/opentasks/src/main/java/org/dmfs/tasks/model/TaskFieldAdapters.java b/opentasks/src/main/java/org/dmfs/tasks/model/TaskFieldAdapters.java index 48886b1ec..adbf0325b 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/model/TaskFieldAdapters.java +++ b/opentasks/src/main/java/org/dmfs/tasks/model/TaskFieldAdapters.java @@ -18,18 +18,24 @@ import android.text.format.Time; +import org.dmfs.jems.optional.Optional; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.RecurrenceRule; import org.dmfs.tasks.contract.TaskContract; import org.dmfs.tasks.contract.TaskContract.Tasks; import org.dmfs.tasks.model.adapters.BooleanFieldAdapter; import org.dmfs.tasks.model.adapters.ChecklistFieldAdapter; import org.dmfs.tasks.model.adapters.ColorFieldAdapter; import org.dmfs.tasks.model.adapters.CustomizedDefaultFieldAdapter; +import org.dmfs.tasks.model.adapters.DateTimeFieldAdapter; import org.dmfs.tasks.model.adapters.DescriptionFieldAdapter; import org.dmfs.tasks.model.adapters.DescriptionStringFieldAdapter; import org.dmfs.tasks.model.adapters.FieldAdapter; import org.dmfs.tasks.model.adapters.FloatFieldAdapter; import org.dmfs.tasks.model.adapters.FormattedStringFieldAdapter; import org.dmfs.tasks.model.adapters.IntegerFieldAdapter; +import org.dmfs.tasks.model.adapters.OptionalLongFieldAdapter; +import org.dmfs.tasks.model.adapters.RRuleFieldAdapter; import org.dmfs.tasks.model.adapters.StringFieldAdapter; import org.dmfs.tasks.model.adapters.TimeFieldAdapter; import org.dmfs.tasks.model.adapters.TimezoneFieldAdapter; @@ -135,6 +141,21 @@ public final class TaskFieldAdapters public final static FieldAdapter

+ * Time values are stored as three values: + *

+ *

+ * This adapter combines those three fields in a {@link ContentValues} to a Time value. If the time zone field is null the time zone is always set + * to UTC. + * + * @author Marten Gajda + */ +public final class DateTimeFieldAdapter extends FieldAdapter> +{ + private final String mTimestampField; + private final String mTzField; + private final String mAllDayField; + private final boolean mAllDayDefault; + + + /** + * Constructor for a new TimeFieldAdapter. + * + * @param timestampField + * The name of the field that holds the time stamp in milliseconds. + * @param tzField + * The name of the field that holds the time zone (as Olson ID). If the field name is null the time is always set to UTC. + * @param alldayField + * The name of the field that indicated that this time is a date not a date-time. If this fieldName is null all loaded values are + * non-allday. + */ + public DateTimeFieldAdapter(String timestampField, String tzField, String alldayField) + { + if (timestampField == null) + { + throw new IllegalArgumentException("timestampField must not be null"); + } + mTimestampField = timestampField; + mTzField = tzField; + mAllDayField = alldayField; + mAllDayDefault = false; + } + + + @Override + public Optional get(ContentSet values) + { + Long timestamp = values.getAsLong(mTimestampField); + if (timestamp == null) + { + // if the time stamp is null we return null + return absent(); + } + // create a new Time for the given time zone, falling back to UTC if none is given + String timezone = mTzField == null ? Time.TIMEZONE_UTC : values.getAsString(mTzField); + DateTime value = new DateTime(timezone == null ? null : TimeZone.getTimeZone(timezone), timestamp); + + // cache mAlldayField locally + String allDayField = mAllDayField; + + // set the allday flag appropriately + Integer allDayInt = allDayField == null ? null : values.getAsInteger(allDayField); + + if ((allDayInt != null && allDayInt != 0) || (allDayField == null && mAllDayDefault)) + { + value = value.toAllDay(); + } + + return new Present<>(value); + } + + + @Override + public Optional get(Cursor cursor) + { + int tsIdx = cursor.getColumnIndex(mTimestampField); + int tzIdx = mTzField == null ? -1 : cursor.getColumnIndex(mTzField); + int adIdx = mAllDayField == null ? -1 : cursor.getColumnIndex(mAllDayField); + + if (tsIdx < 0 || (mTzField != null && tzIdx < 0) || (mAllDayField != null && adIdx < 0)) + { + throw new IllegalArgumentException("At least one column is missing in cursor."); + } + + if (cursor.isNull(tsIdx)) + { + // if the time stamp is null we return null + return absent(); + } + + long timestamp = cursor.getLong(tsIdx); + + // create a new Time for the given time zone, falling back to UTC if none is given + String timezone = mTzField == null ? Time.TIMEZONE_UTC : cursor.getString(tzIdx); + DateTime value = new DateTime(timezone == null ? null : TimeZone.getTimeZone(timezone), timestamp); + + // set the allday flag appropriately + Integer allDayInt = adIdx < 0 ? null : cursor.getInt(adIdx); + + if ((allDayInt != null && allDayInt != 0) || (mAllDayField == null && mAllDayDefault)) + { + value = value.toAllDay(); + } + return new Present<>(value); + } + + + @Override + public Optional getDefault(ContentSet values) + { + // create a new Time for the given time zone, falling back to the default time zone if none is given + String timezone = mTzField == null ? Time.TIMEZONE_UTC : values.getAsString(mTzField); + DateTime value = DateTime.now(timezone == null ? null : TimeZone.getTimeZone(timezone)); + + Integer allDayInt = mAllDayField == null ? null : values.getAsInteger(mAllDayField); + if ((allDayInt != null && allDayInt != 0) || (mAllDayField == null && mAllDayDefault)) + { + // make it an allday value + value = value.toAllDay(); + } + + return new Present<>(value); + } + + + @Override + public void set(ContentSet values, Optional value) + { + values.startBulkUpdate(); + try + { + if (value.isPresent()) + { + DateTime dt = value.value(); + // just store all three parts separately + values.put(mTimestampField, dt.getTimestamp()); + + if (mTzField != null) + { + values.put(mTzField, dt.isFloating() ? null : dt.getTimeZone().getID()); + } + if (mAllDayField != null) + { + values.put(mAllDayField, dt.isAllDay() ? 1 : 0); + } + } + else + { + // write timestamp only, other fields may still use allday and timezone + values.put(mTimestampField, (Long) null); + } + } + finally + { + values.finishBulkUpdate(); + } + } + + + @Override + public void set(ContentValues values, Optional value) + { + if (value.isPresent()) + { + DateTime dt = value.value(); + // just store all three parts separately + values.put(mTimestampField, dt.getTimestamp()); + + if (mTzField != null) + { + values.put(mTzField, dt.isFloating() ? null : dt.getTimeZone().getID()); + } + if (mAllDayField != null) + { + values.put(mAllDayField, dt.isAllDay() ? 1 : 0); + } + } + else + { + // write timestamp only, other fields may still use allday and timezone + values.put(mTimestampField, (Long) null); + } + } + + + @Override + public void registerListener(ContentSet values, OnContentChangeListener listener, boolean initalNotification) + { + values.addOnChangeListener(listener, mTimestampField, initalNotification); + if (mTzField != null) + { + values.addOnChangeListener(listener, mTzField, initalNotification); + } + if (mAllDayField != null) + { + values.addOnChangeListener(listener, mAllDayField, initalNotification); + } + } + + + @Override + public void unregisterListener(ContentSet values, OnContentChangeListener listener) + { + values.removeOnChangeListener(listener, mTimestampField); + if (mTzField != null) + { + values.removeOnChangeListener(listener, mTzField); + } + if (mAllDayField != null) + { + values.removeOnChangeListener(listener, mAllDayField); + } + } +} diff --git a/opentasks/src/main/java/org/dmfs/tasks/model/adapters/OptionalLongFieldAdapter.java b/opentasks/src/main/java/org/dmfs/tasks/model/adapters/OptionalLongFieldAdapter.java new file mode 100644 index 000000000..a06c41792 --- /dev/null +++ b/opentasks/src/main/java/org/dmfs/tasks/model/adapters/OptionalLongFieldAdapter.java @@ -0,0 +1,138 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.tasks.model.ContentSet; +import org.dmfs.tasks.model.OnContentChangeListener; + +import static org.dmfs.jems.optional.elementary.Absent.absent; + + +/** + * Knows how to load and store an {@link Optional} {@link Long} value in a certain field of a {@link ContentSet}. + */ +public class OptionalLongFieldAdapter extends FieldAdapter> +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + /** + * The default value, if any. + */ + private final Optional mDefaultValue; + + + /** + * Constructor for a new IntegerFieldAdapter without default value. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public OptionalLongFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + mDefaultValue = absent(); + } + + + /** + * Constructor for a new IntegerFieldAdapter with default value. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + * @param defaultValue + * The default value. + */ + public OptionalLongFieldAdapter(String fieldName, Optional defaultValue) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + mDefaultValue = defaultValue; + } + + + @Override + public Optional get(ContentSet values) + { + // return the value as Integer + return new FirstPresent<>(new NullSafe<>(values.getAsLong(mFieldName)), mDefaultValue); + } + + + @Override + public Optional get(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The fieldName column missing in cursor."); + } + return cursor.isNull(columnIdx) ? mDefaultValue : new Present<>(cursor.getLong(columnIdx)); + } + + + @Override + public Optional getDefault(ContentSet values) + { + return mDefaultValue; + } + + + @Override + public void set(ContentSet values, Optional value) + { + values.put(mFieldName, value.isPresent() ? value.value() : null); + } + + + @Override + public void set(ContentValues values, Optional value) + { + values.put(mFieldName, value.isPresent() ? value.value() : null); + } + + + @Override + public void registerListener(ContentSet values, OnContentChangeListener listener, boolean initalNotification) + { + values.addOnChangeListener(listener, mFieldName, initalNotification); + } + + + @Override + public void unregisterListener(ContentSet values, OnContentChangeListener listener) + { + values.removeOnChangeListener(listener, mFieldName); + } +} diff --git a/opentasks/src/main/java/org/dmfs/tasks/model/adapters/RRuleFieldAdapter.java b/opentasks/src/main/java/org/dmfs/tasks/model/adapters/RRuleFieldAdapter.java new file mode 100644 index 000000000..6e09f075b --- /dev/null +++ b/opentasks/src/main/java/org/dmfs/tasks/model/adapters/RRuleFieldAdapter.java @@ -0,0 +1,149 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.decorators.Mapped; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.jems.single.adapters.Unchecked; +import org.dmfs.rfc5545.recur.InvalidRecurrenceRuleException; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.dmfs.tasks.model.ContentSet; +import org.dmfs.tasks.model.OnContentChangeListener; + +import static org.dmfs.jems.optional.elementary.Absent.absent; + + +/** + * Knows how to load and store a {@link String} value in a certain field of a {@link ContentSet}. + * + * @author Marten Gajda + */ +public class RRuleFieldAdapter extends FieldAdapter> +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + /** + * The default value, if any. + */ + private final String mDefaultValue; + + + /** + * Constructor for a new StringFieldAdapter without default value. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public RRuleFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + mDefaultValue = null; + } + + + /** + * Constructor for a new StringFieldAdapter with default value. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + * @param defaultValue + * The default value. + */ + public RRuleFieldAdapter(String fieldName, String defaultValue) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + mDefaultValue = defaultValue; + } + + + @Override + public Optional get(ContentSet values) + { + return new Mapped<>(rrule -> new Unchecked<>(() -> new RecurrenceRule(rrule)).value(), new NullSafe<>(values.getAsString(mFieldName))); + } + + + @Override + public Optional get(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The fieldName column missing in cursor."); + } + try + { + return cursor.isNull(columnIdx) ? absent() : new Present<>(new RecurrenceRule(cursor.getString(columnIdx))); + } + catch (InvalidRecurrenceRuleException e) + { + return absent(); + } + } + + + @Override + public Optional getDefault(ContentSet values) + { + return absent(); + } + + + @Override + public void set(ContentSet values, Optional value) + { + values.put(mFieldName, value.isPresent() ? value.value().toString() : null); + } + + + @Override + public void set(ContentValues values, Optional value) + { + values.put(mFieldName, value.isPresent() ? value.value().toString() : null); + } + + + @Override + public void registerListener(ContentSet values, OnContentChangeListener listener, boolean initalNotification) + { + values.addOnChangeListener(listener, mFieldName, initalNotification); + } + + + @Override + public void unregisterListener(ContentSet values, OnContentChangeListener listener) + { + values.removeOnChangeListener(listener, mFieldName); + } +} diff --git a/opentasks/src/main/java/org/dmfs/tasks/widget/RRuleFieldEditor.java b/opentasks/src/main/java/org/dmfs/tasks/widget/RRuleFieldEditor.java new file mode 100644 index 000000000..185d212bf --- /dev/null +++ b/opentasks/src/main/java/org/dmfs/tasks/widget/RRuleFieldEditor.java @@ -0,0 +1,205 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.Button; + +import com.maltaisn.recurpicker.Recurrence; +import com.maltaisn.recurpicker.format.RRuleFormatter; +import com.maltaisn.recurpicker.format.RecurrenceFormatter; + +import org.dmfs.jems.function.Function; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.decorators.Mapped; +import org.dmfs.jems.procedure.composite.ForEach; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.rfc5545.Weekday; +import org.dmfs.rfc5545.recur.Freq; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.dmfs.tasks.model.ContentSet; +import org.dmfs.tasks.model.FieldDescriptor; +import org.dmfs.tasks.model.TaskFieldAdapters; +import org.dmfs.tasks.model.adapters.RRuleFieldAdapter; +import org.dmfs.tasks.model.layout.LayoutOptions; +import org.dmfs.tasks.widget.recurrence.Conditional; +import org.dmfs.tasks.widget.recurrence.NotRepeating; +import org.dmfs.tasks.widget.recurrence.RecurrencePopupGenerator; +import org.dmfs.tasks.widget.recurrence.RepeatByRule; + +import java.text.DateFormat; + +import androidx.appcompat.widget.PopupMenu; + +import static java.util.Arrays.asList; +import static org.dmfs.jems.optional.elementary.Absent.absent; + + +/** + * An editor for the rrule field. + */ +public final class RRuleFieldEditor extends AbstractFieldEditor implements View.OnClickListener +{ + private final static RecurrenceFormatter RECURRENCE_FORMATTER = new RecurrenceFormatter(DateFormat.getDateInstance()); + private final static RRuleFormatter RULE_FORMATTER = new RRuleFormatter(); + + private RRuleFieldAdapter mAdapter; + private Button mButton; + + private final Function, String> ruleStringFunction = rule -> + RECURRENCE_FORMATTER.format( + getContext(), + new Backed<>( + new Mapped<>( + r -> RULE_FORMATTER.parse("RRULE:" + r.toString()), + rule), + () -> Recurrence.DOES_NOT_REPEAT).value()); + + private final RecurrencePopupGenerator mRecurrencePopupInitializer = new RecurrencePopupGenerator( + new NotRepeating(ruleStringFunction), + new RepeatByRule(ruleStringFunction, () -> new RecurrenceRule(Freq.DAILY)), + new RepeatByRule(ruleStringFunction, () -> new RecurrenceRule(Freq.WEEKLY)), + new Conditional( + dateTime -> dateTime.getDayOfWeek() > 0 && dateTime.getDayOfWeek() < 6, // don't show this on weekends + new RepeatByRule(ruleStringFunction, () -> { + RecurrenceRule x = new RecurrenceRule(Freq.WEEKLY); + x.setByDayPart(asList( + new RecurrenceRule.WeekdayNum(0, Weekday.MO), + new RecurrenceRule.WeekdayNum(0, Weekday.TU), + new RecurrenceRule.WeekdayNum(0, Weekday.WE), + new RecurrenceRule.WeekdayNum(0, Weekday.TH), + new RecurrenceRule.WeekdayNum(0, Weekday.FR) + )); + return x; + })), + new RepeatByRule(ruleStringFunction, () -> new RecurrenceRule(Freq.MONTHLY)), + new RepeatByRule(ruleStringFunction, () -> new RecurrenceRule(Freq.YEARLY)) + ); + + + public RRuleFieldEditor(Context context) + { + super(context); + } + + + public RRuleFieldEditor(Context context, AttributeSet attrs) + { + super(context, attrs); + } + + + public RRuleFieldEditor(Context context, AttributeSet attrs, int defStyle) + { + super(context, attrs, defStyle); + } + + + @Override + protected void onFinishInflate() + { + super.onFinishInflate(); + mButton = findViewById(android.R.id.text1); + mButton.setOnClickListener(this); + + } + + + @Override + public void setValue(ContentSet values) + { + if (mValues != null) + { + TaskFieldAdapters.DTSTART_DATETIME.unregisterListener(mValues, this); + TaskFieldAdapters.DUE_DATETIME.unregisterListener(mValues, this); + } + super.setValue(values); + if (mValues != null) + { + TaskFieldAdapters.DTSTART_DATETIME.registerListener(mValues, this, false); + TaskFieldAdapters.DUE_DATETIME.registerListener(mValues, this, false); + } + } + + + @Override + public void setFieldDescription(FieldDescriptor descriptor, LayoutOptions layoutOptions) + { + super.setFieldDescription(descriptor, layoutOptions); + mAdapter = (RRuleFieldAdapter) descriptor.getFieldAdapter(); + mButton.setHint(descriptor.getHint()); + } + + + @Override + public void updateValues() + { + // make sure we don't try to store a recurrence rule for a task without start or due date + if (!new FirstPresent<>( + TaskFieldAdapters.DTSTART_DATETIME.get(mValues), + TaskFieldAdapters.DUE_DATETIME.get(mValues)).isPresent()) + { + mAdapter.set(mValues, absent()); + } + } + + + @Override + public void onContentChanged(ContentSet contentSet) + { + if (!mValues.isInsert() + || !new FirstPresent<>( + TaskFieldAdapters.DTSTART_DATETIME.get(mValues), + TaskFieldAdapters.DUE_DATETIME.get(mValues)).isPresent()) + { + // for now we only show this for newly inserted tasks, because we still need to implement "this and future", etc. + setVisibility(GONE); + } + else + { + setVisibility(VISIBLE); + if (mValues != null) + { + setTitle(mAdapter.get(contentSet)); + } + } + } + + + @Override + public void onClick(View v) + { + new ForEach<>(new FirstPresent<>( + TaskFieldAdapters.DTSTART_DATETIME.get(mValues), + TaskFieldAdapters.DUE_DATETIME.get(mValues))) + .process(start -> { + PopupMenu m = new PopupMenu(getContext(), v); + mRecurrencePopupInitializer.value(start, rule -> mAdapter.set(mValues, rule)).process(m.getMenu()); + m.show(); + }); + } + + + private void setTitle(Optional ruleOptional) + { + mButton.setText(ruleStringFunction.value(ruleOptional)); + } + +} diff --git a/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/Conditional.java b/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/Conditional.java new file mode 100644 index 000000000..95a94acd6 --- /dev/null +++ b/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/Conditional.java @@ -0,0 +1,68 @@ +/* + * Copyright 2021 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.widget.recurrence; + +import android.view.Menu; + +import org.dmfs.jems.function.BiFunction; +import org.dmfs.jems.generator.Generator; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.predicate.Predicate; +import org.dmfs.jems.procedure.Procedure; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.RecurrenceRule; + +import androidx.annotation.NonNull; + + +public final class Conditional implements BiFunction>, Procedure

> +{ + private final Predicate mCondition; + private final BiFunction>, ? extends Procedure> mDelegate; + private final Generator> mDefaultGenerator; + + + public Conditional( + @NonNull Predicate condition, + @NonNull BiFunction>, ? extends Procedure> delegate) + { + this(condition, delegate, () -> ignored -> { + }); + } + + + public Conditional( + @NonNull Predicate condition, + @NonNull BiFunction>, ? extends Procedure> delegate, + @NonNull Generator> defaultGenerator) + { + mCondition = condition; + mDelegate = delegate; + mDefaultGenerator = defaultGenerator; + } + + + @Override + public Procedure value(DateTime dateTime, Procedure> recurrenceRuleProcedure) + { + if (mCondition.satisfiedBy(dateTime)) + { + return mDelegate.value(dateTime, recurrenceRuleProcedure); + } + return mDefaultGenerator.next(); + } +} diff --git a/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/NotRepeating.java b/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/NotRepeating.java new file mode 100644 index 000000000..9f2626f23 --- /dev/null +++ b/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/NotRepeating.java @@ -0,0 +1,51 @@ +/* + * Copyright 2021 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.widget.recurrence; + +import android.view.Menu; + +import org.dmfs.jems.function.BiFunction; +import org.dmfs.jems.function.Function; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.procedure.Procedure; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.RecurrenceRule; + +import static org.dmfs.jems.optional.elementary.Absent.absent; + + +public final class NotRepeating implements BiFunction>, Procedure> +{ + private final Function, String> mRruleToStringFunction; + + + public NotRepeating(Function, String> rruleToStringFunction) + { + mRruleToStringFunction = rruleToStringFunction; + } + + + @Override + public Procedure value(DateTime dateTime, Procedure> recurrenceRuleProcedure) + { + return menu -> menu.add(mRruleToStringFunction.value(absent())) + .setOnMenuItemClickListener(item -> { + recurrenceRuleProcedure.process(absent()); + return true; + }); + } +} diff --git a/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/RecurrencePopupGenerator.java b/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/RecurrencePopupGenerator.java new file mode 100644 index 000000000..b03cb9b19 --- /dev/null +++ b/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/RecurrencePopupGenerator.java @@ -0,0 +1,53 @@ +/* + * Copyright 2021 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.widget.recurrence; + +import android.view.Menu; + +import org.dmfs.jems.function.BiFunction; +import org.dmfs.jems.iterable.elementary.Seq; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.procedure.Procedure; +import org.dmfs.jems.procedure.composite.ForEach; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.RecurrenceRule; + + +public final class RecurrencePopupGenerator implements BiFunction>, Procedure> +{ + private final Iterable>, ? extends Procedure>> delegates; + + + @SafeVarargs + public RecurrencePopupGenerator(BiFunction>, ? extends Procedure>... delegates) + { + this(new Seq<>(delegates)); + } + + + public RecurrencePopupGenerator(Iterable>, ? extends Procedure>> delegates) + { + this.delegates = delegates; + } + + + @Override + public Procedure value(DateTime dateTime, Procedure> recurrenceRuleProcedure) + { + return menu -> new ForEach<>(delegates).process(d -> d.value(dateTime, recurrenceRuleProcedure).process(menu)); + } +} diff --git a/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/RepeatByRule.java b/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/RepeatByRule.java new file mode 100644 index 000000000..c0a5843ac --- /dev/null +++ b/opentasks/src/main/java/org/dmfs/tasks/widget/recurrence/RepeatByRule.java @@ -0,0 +1,56 @@ +/* + * Copyright 2021 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.widget.recurrence; + +import android.view.Menu; + +import org.dmfs.jems.function.BiFunction; +import org.dmfs.jems.function.Function; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.jems.procedure.Procedure; +import org.dmfs.jems.single.Single; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.RecurrenceRule; + +import androidx.annotation.NonNull; + + +public final class RepeatByRule implements BiFunction>, Procedure> +{ + private final Function, String> mRruleToStringFunction; + private final Single mRuleSingle; + + + public RepeatByRule(Function, String> rruleToStringFunction, @NonNull Single ruleSingle) + { + mRruleToStringFunction = rruleToStringFunction; + mRuleSingle = ruleSingle; + } + + + @Override + public Procedure value(DateTime dateTime, Procedure> recurrenceRuleProcedure) + { + Optional rrule = new Present<>(mRuleSingle.value()); + return menu -> menu.add(mRruleToStringFunction.value(rrule)) + .setOnMenuItemClickListener(item -> { + recurrenceRuleProcedure.process(rrule); + return true; + }); + } +} diff --git a/opentasks/src/main/play/release-notes/en-GB/production.txt b/opentasks/src/main/play/release-notes/en-GB/production.txt index 169bdf166..5c3d63d0e 100644 --- a/opentasks/src/main/play/release-notes/en-GB/production.txt +++ b/opentasks/src/main/play/release-notes/en-GB/production.txt @@ -1 +1 @@ -see https://github.com/dmfs/opentasks/releases/tag/1.3.1 +see https://github.com/dmfs/opentasks/releases/tag/1.4.0 diff --git a/opentasks/src/main/res/drawable/ic_baseline_repeat_24.xml b/opentasks/src/main/res/drawable/ic_baseline_repeat_24.xml new file mode 100644 index 000000000..46b72a61b --- /dev/null +++ b/opentasks/src/main/res/drawable/ic_baseline_repeat_24.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/opentasks/src/main/res/layout/opentasks_rrule_field_editor.xml b/opentasks/src/main/res/layout/opentasks_rrule_field_editor.xml new file mode 100644 index 000000000..a1b60f8c3 --- /dev/null +++ b/opentasks/src/main/res/layout/opentasks_rrule_field_editor.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/opentasks/src/main/res/values/ids.xml b/opentasks/src/main/res/values/ids.xml index ebc4b5b6d..b053cc3c0 100644 --- a/opentasks/src/main/res/values/ids.xml +++ b/opentasks/src/main/res/values/ids.xml @@ -18,6 +18,8 @@ type="id"/> + Send Send to + + Delete recurring task + + All occurrences + + This occurrence + Text @@ -93,6 +100,7 @@ Priority Time Zone All Day + Recurrence No due date