Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mobile: Added functionality of adding due dates for todos and sorting on their basis #2937

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion ReactNativeClient/lib/components/screens/note.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class NoteScreenComponent extends BaseScreenComponent {
fromShare: false,
showCamera: false,
noteResources: {},
dueDateShown: false,

// HACK: For reasons I can't explain, when the WebView is present, the TextInput initially does not display (It's just a white rectangle with
// no visible text). It will only appear when tapping it or doing certain action like selecting text on the webview. The bug started to
Expand Down Expand Up @@ -189,6 +190,8 @@ class NoteScreenComponent extends BaseScreenComponent {
this.cameraView_onPhoto = this.cameraView_onPhoto.bind(this);
this.cameraView_onCancel = this.cameraView_onCancel.bind(this);
this.properties_onPress = this.properties_onPress.bind(this);
this.dueDate_onPress = this.dueDate_onPress.bind(this);
this.onDueDateReject = this.onDueDateReject.bind(this);
this.showOnMap_onPress = this.showOnMap_onPress.bind(this);
this.onMarkForDownload = this.onMarkForDownload.bind(this);
this.sideMenuOptions = this.sideMenuOptions.bind(this);
Expand Down Expand Up @@ -589,7 +592,22 @@ class NoteScreenComponent extends BaseScreenComponent {
this.props.dispatch({ type: 'SIDE_MENU_OPEN' });
}

async dueDate_onPress(date) {
const newNote = Object.assign({}, this.state.note);
newNote.due_date = date ? date.getTime() : 0;

await this.saveOneProperty('due_date', date ? date.getTime() : 0);

this.setState({ dueDateShown: false });
}

onDueDateReject() {
this.setState({ dueDateShown: false });
}

setAlarm_onPress() {
// this will make sure due date popup is closed before opening alarm popup
this.setState({ dueDateShown: false });
this.setState({ alarmDialogShown: true });
}

Expand Down Expand Up @@ -699,6 +717,8 @@ class NoteScreenComponent extends BaseScreenComponent {
output.push({
title: _('Set alarm'),
onPress: () => {
// this wil make sure due date popup is closed before alarm popup
this.setState({ dueDateShown: false });
this.setState({ alarmDialogShown: true });
},
});
Expand Down Expand Up @@ -732,6 +752,15 @@ class NoteScreenComponent extends BaseScreenComponent {
},
});
}
if (isTodo) {
output.push({
title: _('Choose a Due Date'),
onPress: () => {
this.setState({ alarmDialogShown: false });
this.setState({ dueDateShown: true });
},
});
}
output.push({
title: _('Properties'),
onPress: () => {
Expand Down Expand Up @@ -975,8 +1004,12 @@ class NoteScreenComponent extends BaseScreenComponent {

const titleContainerStyle = isTodo ? this.styles().titleContainerTodo : this.styles().titleContainer;

// this due date if for alarm of todos, has nothing to do with their expire
const dueDate = Note.dueDateObject(note);

// this is the date for their expire and has nothing to do with alarm time.
const expireDate = Note.dueDateObjectForExpire(note);

const titleComp = (
<View style={titleContainerStyle}>
{isTodo && <Checkbox style={this.styles().checkbox} checked={!!Number(note.todo_completed)} onChange={this.todoCheckbox_change} />}
Expand All @@ -986,14 +1019,26 @@ class NoteScreenComponent extends BaseScreenComponent {

const noteTagDialog = !this.state.noteTagDialogShown ? null : <NoteTagsDialog onCloseRequested={this.noteTagDialog_closeRequested} />;

// this wil shown if user chooses to set an alarm
const alarmDatePopup = (
<SelectDateTimeDialog type='alarm' shown={this.state.alarmDialogShown} date={dueDate} onAccept={this.onAlarmDialogAccept} onReject={this.onAlarmDialogReject} />
);

// this will be shown if user chooses to set a due date (expire date) for note.
const dueDatePopup = (
<SelectDateTimeDialog type='expire_date' shown={this.state.dueDateShown} date={expireDate} onAccept={this.dueDate_onPress} onReject={this.onDueDateReject} />
);

const DateComp = this.state.alarmDialogShown ? alarmDatePopup : dueDatePopup;

return (
<View style={this.rootStyle(this.props.theme).root}>
<ScreenHeader folderPickerOptions={this.folderPickerOptions()} menuOptions={this.menuOptions()} showSaveButton={showSaveButton} saveButtonDisabled={saveButtonDisabled} onSaveButtonPress={this.saveNoteButton_press} showSideMenuButton={false} showSearchButton={false} />
{titleComp}
{bodyComponent}
{!Setting.value('editor.beta') && actionButtonComp}

<SelectDateTimeDialog shown={this.state.alarmDialogShown} date={dueDate} onAccept={this.onAlarmDialogAccept} onReject={this.onAlarmDialogReject} />
{DateComp}

<DialogBox
ref={dialogbox => {
Expand Down
20 changes: 18 additions & 2 deletions ReactNativeClient/lib/components/select-date-time-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,32 @@ class SelectDateTimeDialog extends React.PureComponent {
render() {
const clearAlarmText = _('Clear alarm'); // For unknown reasons, this particular string doesn't get translated if it's directly in the text property below

const popupActions = [
// these are the popup actions which will be used for alarm time popup

const popupActionsForAlarm = [
<DialogButton text={_('Save alarm')} align="center" onPress={() => this.onAccept()} key="saveButton" />,
<DialogButton text={clearAlarmText} align="center" onPress={() => this.onClear()} key="clearButton" />,
<DialogButton text={_('Cancel')} align="center" onPress={() => this.onReject()} key="cancelButton" />,
];

// these are the popup actions which will be used for due date (expire date of todos) popup

const popupActionsForDueDate = [
<DialogButton text={_('Set Due Date')} align="center" onPress={() => this.onAccept()} key="saveButton" />,
<DialogButton text={_('Clear Due Date')} align="center" onPress={() => this.onClear()} key="clearButton" />,
<DialogButton text={_('Cancel')} align="center" onPress={() => this.onReject()} key="cancelButton" />,
];

const type = this.props.type;

const popupActions = type === 'alarm' ? popupActionsForAlarm : popupActionsForDueDate;

const title = type === 'alarm' ? _('Set alarm') : _('Set due date');

return (
<PopupDialog
ref={(dialog) => { this.dialog_ = dialog; }}
dialogTitle={<DialogTitle title={_('Set alarm')} />}
dialogTitle={<DialogTitle title={title} />}
actions={popupActions}
dismissOnTouchOutside={false}
width={0.9}
Expand Down
9 changes: 8 additions & 1 deletion ReactNativeClient/lib/joplin-database.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ const { sprintf } = require('sprintf-js');
const Resource = require('lib/models/Resource');
const { shim } = require('lib/shim.js');

// todo_due is due time of alarms in notes and due_date is for their
// expire. It is used to sort them on the basis of their expire date

const structureSql = `
CREATE TABLE folders (
id TEXT PRIMARY KEY,
Expand Down Expand Up @@ -308,7 +311,7 @@ class JoplinDatabase extends Database {
// must be set in the synchronizer too.

// Note: v16 and v17 don't do anything. They were used to debug an issue.
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28];
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28,29];

let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);

Expand Down Expand Up @@ -676,6 +679,10 @@ class JoplinDatabase extends Database {
queries.push('CREATE INDEX resources_size ON resources(size)');
}

if (targetVersion == 29) {
queries.push('ALTER TABLE notes ADD COLUMN `due_date` INT DEFAULT 0');
}

queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });

try {
Expand Down
130 changes: 129 additions & 1 deletion ReactNativeClient/lib/models/Note.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class Note extends BaseItem {
title: _('title'),
user_updated_time: _('updated date'),
user_created_time: _('created date'),
// it is expire date of notes and not the alarm date
due_date: _('Due Date'),
};

return field in fieldsToLabels ? fieldsToLabels[field] : field;
Expand Down Expand Up @@ -73,6 +75,7 @@ class Note extends BaseItem {
lodash.pull(fieldNames, 'created_time');
lodash.pull(fieldNames, 'updated_time');
lodash.pull(fieldNames, 'order');
lodash.pull(fieldNames, 'due_date');

return super.serialize(n, fieldNames);
}
Expand Down Expand Up @@ -210,6 +213,11 @@ class Note extends BaseItem {
return uncompletedTodosOnTop && note.is_todo && !note.todo_completed;
};

// this function will return true if the due date is set by the user for the todo, otherwise false
const dueDateSet = note => {
return !(note.due_date == 0 || note.due_date == null);
};

const noteFieldComp = (f1, f2) => {
if (f1 === f2) return 0;
return f1 < f2 ? -1 : +1;
Expand All @@ -232,6 +240,40 @@ class Note extends BaseItem {
return noteFieldComp(a.id, b.id);
};

// this is to check if sorted order is based on due_date
const res = orders.find(order => order.by == 'due_date');

if (res) {
// if sorting is based on due_date, then follow these rules
const sortedNotes = notes.sort((a, b) =>{
// todos have to be above notes
if (a.is_todo && !b.is_todo) return -1;
if (!a.is_todo && b.is_todo) return +1;

// notes can be sorted on basis of sortIdenticalNotes function (which is updated and created_time)
if (!a.is_todo && !b.is_todo) return sortIdenticalNotes(a, b);

// in todos incomplete todos have to be above completed todos
if (a.todo_completed && !b.todo_completed) return +1;
if (!a.todo_completed && b.todo_completed) return -1;

// in all incomplete todos, the one with due dates set by user have to be on top
if (res.dir == 'ASC') {
if (dueDateSet(a) && dueDateSet(b)) return new Date(a.due_date) - new Date(b.due_date);
} else {
if (dueDateSet(a) && dueDateSet(b)) return new Date(b.due_date) - new Date(a.due_date);
}

// if one note has due date set and one hasn't, then the one with due date will be on top
if (dueDateSet(a) && !dueDateSet(b)) return -1;
if (!dueDateSet(a) && dueDateSet(b)) return +1;

return sortIdenticalNotes(a, b);
});

return sortedNotes;
}

return notes.sort((a, b) => {
if (noteOnTop(a) && !noteOnTop(b)) return -1;
if (!noteOnTop(a) && noteOnTop(b)) return +1;
Expand All @@ -256,7 +298,7 @@ class Note extends BaseItem {

static previewFields() {
// return ['id', 'title', 'body', 'is_todo', 'todo_completed', 'parent_id', 'updated_time', 'user_updated_time', 'user_created_time', 'encryption_applied'];
return ['id', 'title', 'is_todo', 'todo_completed', 'parent_id', 'updated_time', 'user_updated_time', 'user_created_time', 'encryption_applied'];
return ['id', 'title', 'is_todo', 'todo_completed', 'parent_id', 'updated_time', 'user_updated_time', 'user_created_time', 'encryption_applied','due_date'];
}

static previewFieldsSql(fields = null) {
Expand Down Expand Up @@ -322,6 +364,71 @@ class Note extends BaseItem {
options.conditions.push('todo_completed <= 0');
}

// this is to check if we have to sort by due_date, if yes, then we will use if condition written below
const sortByDueDate = options.order.find(order => order.by == 'due_date');

// these will be cases to sort on basis of due_date:
// 1 incomplete todos with less due date will be on top
// 2 incomplete todos with more due date afterwards and then incomplete todos with no due dates.
// 3 completes todos with less due date
// 4 complete todos with more due date
// 5 complete todos with no due dates
// 6 notes ( sorted on their updated time, created time, id )
if (sortByDueDate && hasTodos) {
let conditions = options.conditions.slice();
conditions.push('is_todo = 1');
conditions.push('(todo_completed <= 0 OR todo_completed IS NULL)');
conditions.push('(due_date != 0 AND due_date IS NOT NULL)');
let tempOptions = Object.assign({}, options);
tempOptions.conditions = conditions;

const todosWithDueDateNotCompleted = await this.search(tempOptions);

conditions = options.conditions.slice();
conditions.push('is_todo = 1');
conditions.push('(todo_completed <= 0 OR todo_completed IS NULL)');
conditions.push('(due_date == 0 OR due_date IS NULL)');
tempOptions = Object.assign({}, options);
tempOptions.conditions = conditions;

const todosWithoutDueDateNotCompleted = await this.search(tempOptions);

const todosNotCompleted = todosWithDueDateNotCompleted.concat(todosWithoutDueDateNotCompleted);

conditions = options.conditions.slice();
conditions.push('(is_todo = 1 AND todo_completed > 0)');
conditions.push('(due_date != 0 AND due_date IS NOT NULL)');
tempOptions = Object.assign({}, options);
tempOptions.conditions = conditions;

const todosWithDueDateCompleted = await this.search(tempOptions);

conditions = options.conditions.slice();
conditions.push('(is_todo = 1 AND todo_completed > 0)');
conditions.push('(due_date == 0 OR due_date IS NULL)');
tempOptions = Object.assign({}, options);
tempOptions.conditions = conditions;

const todosWithoutDueDateCompleted = await this.search(tempOptions);

const todosCompleted = todosWithDueDateCompleted.concat(todosWithoutDueDateCompleted);

const totalTodos = todosNotCompleted.concat(todosCompleted);

if (hasTodos && hasNotes) {
conditions = options.conditions.slice();
conditions.push('is_todo == 0');
tempOptions = Object.assign({}, options);
tempOptions.conditions = conditions;

const totalNotes = await this.search(tempOptions);

return totalTodos.concat(totalNotes);
}

return totalTodos;
}

if (options.uncompletedTodosOnTop && hasTodos) {
let cond = options.conditions.slice();
cond.push('is_todo = 1');
Expand Down Expand Up @@ -628,6 +735,7 @@ class Note extends BaseItem {
return note.is_todo && !note.todo_completed && note.todo_due >= time.unixMs() && !note.is_conflict;
}

// this will return alarm time of todos, it has nothing to do with expire date
static dueDateObject(note) {
if (!!note.is_todo && note.todo_due) {
if (!this.dueDateObjects_) this.dueDateObjects_ = {};
Expand All @@ -639,6 +747,26 @@ class Note extends BaseItem {
return null;
}

// this will return date of due date of todos (here due date means expire and not for alarms)
static dueDateObjectForExpire(note) {
if (!!note.is_todo && note.due_date) {
// if the object is not present, it will make an empty object
if (!this.dueDateObjects) {
this.dueDateObjects = {};
}

// if the object is already present, it will return that object
if (this.dueDateObjects[note.due_date]) {
return this.dueDateObjects[note.due_date];
}
// otherwise it will assign the object the due date (expire date) of the note and will return it
this.dueDateObjects[note.due_date] = new Date(note.due_date);
return this.dueDateObjects[note.due_date];
}

return null;
}

// Tells whether the conflict between the local and remote note can be ignored.
static mustHandleConflict(localNote, remoteNote) {
// That shouldn't happen so throw an exception
Expand Down
2 changes: 1 addition & 1 deletion ReactNativeClient/lib/models/Setting.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ class Setting extends BaseModel {
label: () => _('Sort notes by'),
options: () => {
const Note = require('lib/models/Note');
const noteSortFields = ['user_updated_time', 'user_created_time', 'title'];
const noteSortFields = ['user_updated_time', 'user_created_time', 'title','due_date'];
const options = {};
for (let i = 0; i < noteSortFields.length; i++) {
options[noteSortFields[i]] = toTitleCase(Note.fieldToLabel(noteSortFields[i]));
Expand Down
1 change: 1 addition & 0 deletions ReactNativeClient/lib/synchronizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,7 @@ class Synchronizer {
// Let's leave these two lines for 6 months, by which time all the clients should have been synced.
if (!content.user_updated_time) content.user_updated_time = content.updated_time;
if (!content.user_created_time) content.user_created_time = content.created_time;
if (!content.due_date) content.due_date = 0;

const options = {
autoTimestamp: false,
Expand Down