From 4079f5c369b8887f0269e56b87ecd6032db6cef8 Mon Sep 17 00:00:00 2001 From: Philip Andresen Date: Sun, 11 Jun 2023 21:53:48 -0400 Subject: [PATCH 01/11] Initial commit - menu system for reminders --- apps/remindr/app.js | 262 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 apps/remindr/app.js diff --git a/apps/remindr/app.js b/apps/remindr/app.js new file mode 100644 index 000000000..e847dccc4 --- /dev/null +++ b/apps/remindr/app.js @@ -0,0 +1,262 @@ +const textInput = require("textinput"); +g.clearRect(Bangle.appRect); +g.reset(); +Bangle.loadWidgets(); +Bangle.drawWidgets(); + +function createButton(x, y, w, h, text, callback) { + text = text || ""; + const x2 = x + w; + const y2 = y + h; + const r = 8; + const padding = 2; + const getDrawable = (xOff, yOff, wOff, hOff, rOff) => { + xOff = xOff || 0; + yOff = yOff || 0; + wOff = wOff || 0; + hOff = hOff || 0; + rOff = rOff || 0; + return {x: x + xOff, y: y + yOff, x2: x2 + wOff, y2: y2 + hOff, r: r + rOff}; + }; + let onTouch; + if (callback) { + onTouch = (button, xy) => { + const isTouched = x < xy.x && x2 > xy.x && y < xy.y && y2 > xy.y; + if (isTouched) { + Bangle.buzz(30, 1); + return callback(); + } + }; + } + return { + x, y, w, h, r, text, getDrawable, padding, onTouch + }; +} + +function drawButton(button) { + const textMaxWidth = button.w - 2 * button.padding; + let textOutlineCol = g.theme.bgH; + let textCol = g.theme.fg; + if (button.onTouch) { + g.setColor(g.theme.fg) + .fillRect(button.getDrawable()); + g.setColor(g.theme.bg) + .fillRect(button.getDrawable(2, 1, -1, -1)); + g.setColor(g.theme.bg2) + .fillRect(button.getDrawable(3, 3, -3, -3)); + textOutlineCol = g.theme.bg; + textCol = g.theme.fg; + } + const font = getBestFontForButton(button); + // Wrap sometimes adds a line break at the beginning if your string is very small. + // So we filter out any elements that are empty. + const wrapText = g.setFont(font) + .wrapString(button.text, textMaxWidth) + .filter(t => !!t) + .join("\n"); + g.setFontAlign(0, 0) + .setColor(textOutlineCol) + .drawString(wrapText, button.x + button.w / 2 + 1, button.y + button.h / 2 - 1, false); + g.setFontAlign(0, 0) + .setColor(textOutlineCol) + .drawString(wrapText, button.x + button.w / 2 - 1, button.y + button.h / 2 - 1, false); + g.setFontAlign(0, 0) + .setColor(textOutlineCol) + .drawString(wrapText, button.x + button.w / 2 - 1, button.y + button.h / 2 + 1, false); + g.setFontAlign(0, 0) + .setColor(textOutlineCol) + .drawString(wrapText, button.x + button.w / 2 + 1, button.y + button.h / 2 + 1, false); + g.setFontAlign(0, 0) + .setColor(textCol) + .drawString(wrapText, button.x + button.w / 2, button.y + button.h / 2, false); + g.reset(); +} + +function getBestFontForButton(button) { + const allowedFonts = ["12x20", "6x15", "6x8", "4x6"]; + let stringMet = g.setFont("Vector:100") + .stringMetrics(button.text); + let stringArea = stringMet.width * stringMet.height; + const sampleMetric = g.setFont("Vector:100") + .stringMetrics("D"); + const vectorRatio = sampleMetric.height / sampleMetric.width; + // Effective height helps us handle tall skinny buttons, since text is usually horizontal. + let effectiveHeight = Math.min(button.h, button.w); + if (!button.text.includes(" ")) { + effectiveHeight = effectiveHeight / vectorRatio + } + const buttonArea = button.w * effectiveHeight; + const ratio = stringArea / buttonArea; + const vecSize = Math.floor(100 / ratio); + if (vecSize > 20) { + return "Vector:" + vecSize; + } + let i; + for (i = 0; i < allowedFonts.length - 1; i++) { + stringMet = g.setFont(allowedFonts[i]) + .stringMetrics(button.text); + stringArea = Math.max(stringMet.width, button.w) * stringMet.height; + if (stringArea < buttonArea * 0.8) { + break; + } + } + return allowedFonts[i]; +} + +function createSwipeControl(rot, text, callback) { + let draw = () => {}; + let appRect = Bangle.appRect; + let isSwiped = () => {}; + switch (rot) { + case 0: + draw = () => drawSwipeHint(appRect.x + appRect.w / 2, appRect.y + appRect.h - 6, 0, text); + isSwiped = (LR, UD) => LR === 1; + break; + case 1: + case -3: + draw = () => drawSwipeHint(appRect.x + 6, appRect.y + appRect.h / 2, 1, text); + isSwiped = (LR, UD) => UD === 1; + break; + case 2: + case -2: + draw = () => drawSwipeHint(appRect.x + appRect.w / 2, appRect.y + appRect.h - 16, 0, text, true); + isSwiped = (LR, UD) => LR === -1; + break; + case 3: + case -1: + draw = () => drawSwipeHint(appRect.x + appRect.w - 6, appRect.y + appRect.h / 2, 3, text); + isSwiped = (LR, UD) => UD === -1; + break; + } + const onSwipe = (LR, UD) => { + if (isSwiped(LR, UD)) { + return callback(); + } + } + return {draw, onSwipe, rot}; +} + +function drawSwipeHint(x, y, rot, text, flip) { + const tw = g.setFont("6x8") + .stringWidth(text); + const w = tw + 41; + const gRot = Graphics.createArrayBuffer(w, 8, 1, {msb: true}); + gRot.setFont("6x8") + .setFontAlign(0, -1) + .drawString(text, w / 2, 1); + gRot.drawLine(0, 4, (w - tw) / 2 - 4, 4); + gRot.drawLine((w + tw + 4) / 2, 4, w, 4); + if (flip) { + gRot.drawLine(0, 4, 4, 1); + gRot.drawLine(0, 4, 4, 7); + } else { + gRot.drawLine(w, 4, w - 4, 1); + gRot.drawLine(w, 4, w - 4, 7); + } + g.setColor(g.theme.fg) + .drawImage(gRot, x, y, {rotate: Math.PI / 2 * rot}); +} + +function createMenu(options) { + let width = options.width || Bangle.appRect.w; + let height = options.height || Bangle.appRect.h; + let offsetY = Bangle.appRect.y; + const spaceBetween = options.spaceBetween || 5; + const spaceAround = options.spaceAround || 5; + const titleFont = options.titleFont || "12x20"; + let marginTop = 0; + let marginBottom = 0; + let marginLeft = 0; + let marginRight = 0; + const swipeControls = options.swipeControls || []; + swipeControls.forEach(control => { + if (control.rot === 0) marginBottom += 8; + if (control.rot === 1) marginLeft += 8; + if (control.rot === 2) marginBottom += 8; + if (control.rot === 3) marginRight += 8; + }); + if (options.title) { + const mets = g.setFont(titleFont) + .stringMetrics(options.title); + marginTop += mets.height; + } + height = height - marginTop - marginBottom; + width = width - marginLeft - marginRight; + const isHorizontal = !!options.isHorizontal; + const numGridSpaces = options.items.reduce((acc, item) => (acc + (item.size || 1)), 0); + const shortDim = isHorizontal ? width : height; + const length = ((shortDim - spaceBetween) / numGridSpaces) - spaceAround; + const buttons = []; + let currentGrid = 0; + options.items.forEach((item, index) => { + let x, y, w, h; + const mySize = item.size || 1; + const myLength = length * mySize + spaceBetween * (mySize - 1); + if (isHorizontal) { + x = spaceAround + currentGrid * (length + spaceBetween) + marginLeft; + y = spaceAround + marginTop + offsetY; + w = myLength; + h = height - 2 * spaceAround; + } else { + x = spaceAround + marginLeft; + y = spaceAround + currentGrid * (length + spaceBetween) + marginTop + offsetY; + w = width - 2 * spaceAround; + h = myLength; + } + currentGrid += item.size || 1; + buttons.push(createButton(x, y, w, h, item.text, item.callback)); + }) + + function render() { + buttons.forEach(drawButton); + if (options.title) { + g.setFont(titleFont) + .setFontAlign(0, -1) + .drawString(options.title, width / 2 + marginLeft, offsetY); + } + swipeControls.forEach(control => control.draw()); + } + + const touchFunc = (button, xy) => buttons.forEach(b => b.onTouch && b.onTouch(button, xy)); + const swipeFunc = (LR, UD) => swipeControls.forEach(s => s.onSwipe(LR, UD)); + return { + buttons, render, setUI: () => Bangle.setUI({mode: "custom", touch: touchFunc, swipe: swipeFunc}) + }; +} + + +function setMenu(menu) { + g.clearRect(Bangle.appRect); + g.reset(); + menu.render(); + menu.setUI(); +} + +let keyboard; +if (textInput.generateKeyboard) { + const charSet = textInput.createCharSet("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ["spc", "ok", "del"]); + keyboard = textInput.generateKeyboard(charSet); +} + +function createTask() { + textInput.input({text: "", keyboardMain: keyboard}) + .then(text => { + const newMenu = createMenu({items: [{text}], swipeControls: taskSwipeControls}); + g.clear(); + setMenu(newMenu); + }) +} + +const taskSwipeControls = [ + createSwipeControl(2, "Menu", () => setMenu(m1)), createSwipeControl(0, "New Task", createTask), +]; + +const m1 = createMenu({ + title : "Working Memory", items: [ + {text: "New Task", size: 2, callback: createTask}, { + text: "Manage", size: 1, callback: () => console.log("SETTINGS") + } + ], isHorizontal: false +}); + +setMenu(m1); From c10a1a62cb2180c3ecebe1fe63294fab0d6e9d27 Mon Sep 17 00:00:00 2001 From: Philip Andresen Date: Sun, 11 Jun 2023 23:20:00 -0400 Subject: [PATCH 02/11] Add task construct --- apps/remindr/app.js | 47 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/apps/remindr/app.js b/apps/remindr/app.js index e847dccc4..3780aef5e 100644 --- a/apps/remindr/app.js +++ b/apps/remindr/app.js @@ -1,9 +1,14 @@ const textInput = require("textinput"); + g.clearRect(Bangle.appRect); g.reset(); Bangle.loadWidgets(); Bangle.drawWidgets(); +let currentTaskRef; +const completedTasks = []; +const incompleteTasks = []; + function createButton(x, y, w, h, text, callback) { text = text || ""; const x2 = x + w; @@ -33,6 +38,7 @@ function createButton(x, y, w, h, text, callback) { }; } + function drawButton(button) { const textMaxWidth = button.w - 2 * button.padding; let textOutlineCol = g.theme.bgH; @@ -169,12 +175,14 @@ function createMenu(options) { let marginLeft = 0; let marginRight = 0; const swipeControls = options.swipeControls || []; + // Add some margin space to fit swipe control hints if they exist. swipeControls.forEach(control => { if (control.rot === 0) marginBottom += 8; if (control.rot === 1) marginLeft += 8; if (control.rot === 2) marginBottom += 8; if (control.rot === 3) marginRight += 8; }); + // Add top margin to fit the title. if (options.title) { const mets = g.setFont(titleFont) .stringMetrics(options.title); @@ -187,10 +195,12 @@ function createMenu(options) { const shortDim = isHorizontal ? width : height; const length = ((shortDim - spaceBetween) / numGridSpaces) - spaceAround; const buttons = []; + // currentGrid tracks what grid square we are covering. Any item may cover multiple grid squares. let currentGrid = 0; - options.items.forEach((item, index) => { + options.items.forEach((item) => { let x, y, w, h; const mySize = item.size || 1; + // myLength represents the shorter of the two dimensions of the button (depending on menu orientation, w / h). const myLength = length * mySize + spaceBetween * (mySize - 1); if (isHorizontal) { x = spaceAround + currentGrid * (length + spaceBetween) + marginLeft; @@ -238,22 +248,45 @@ if (textInput.generateKeyboard) { keyboard = textInput.generateKeyboard(charSet); } -function createTask() { - textInput.input({text: "", keyboardMain: keyboard}) +function newTask(initialText) { + initialText = initialText || ""; + textInput.input({text: initialText, keyboardMain: keyboard}) .then(text => { - const newMenu = createMenu({items: [{text}], swipeControls: taskSwipeControls}); + const task = createTask(text) + incompleteTasks.unshift(task); + currentTaskRef = task; g.clear(); - setMenu(newMenu); + Bangle.drawWidgets(); + setMenu(task.getMenu()); }) } const taskSwipeControls = [ - createSwipeControl(2, "Menu", () => setMenu(m1)), createSwipeControl(0, "New Task", createTask), + createSwipeControl(2, "Menu", () => setMenu(m1)), + createSwipeControl(0, "New Task", newTask), + createSwipeControl(1, "Edit Task", () => newTask("Initial text")) // Placeholder ]; +function createTask(text) { + const incrementalBackoffSet = [0.5, 1, 2, 4, 8, 16, 32]; + const getMenu = () => createMenu({ + items: [{text}], spaceAround: 0, spaceBetween: 0, swipeControls: taskSwipeControls + }); + return { + text, + affirmCount : 0, + distractCount : 0, + unresponsiveCount: 0, + interval : 30, + backoffIndex : 1, + incrementalBackoffSet, + getMenu + }; +} + const m1 = createMenu({ title : "Working Memory", items: [ - {text: "New Task", size: 2, callback: createTask}, { + {text: "New Task", size: 2, callback: newTask}, { text: "Manage", size: 1, callback: () => console.log("SETTINGS") } ], isHorizontal: false From e191c5cb39ed8f41b166e9edd000ce19b6245c56 Mon Sep 17 00:00:00 2001 From: Philip Andresen Date: Mon, 12 Jun 2023 11:30:47 -0400 Subject: [PATCH 03/11] Add tasks lifecycle methods --- apps/remindr/app.js | 108 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 87 insertions(+), 21 deletions(-) diff --git a/apps/remindr/app.js b/apps/remindr/app.js index 3780aef5e..59c7b9128 100644 --- a/apps/remindr/app.js +++ b/apps/remindr/app.js @@ -2,12 +2,12 @@ const textInput = require("textinput"); g.clearRect(Bangle.appRect); g.reset(); +E.showMessage("Loading ... "); Bangle.loadWidgets(); Bangle.drawWidgets(); let currentTaskRef; -const completedTasks = []; -const incompleteTasks = []; +const allTasks = []; function createButton(x, y, w, h, text, callback) { text = text || ""; @@ -242,36 +242,62 @@ function setMenu(menu) { menu.setUI(); } -let keyboard; +let keyboardAlpha, keyboardNum; if (textInput.generateKeyboard) { const charSet = textInput.createCharSet("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ["spc", "ok", "del"]); - keyboard = textInput.generateKeyboard(charSet); + keyboardAlpha = textInput.generateKeyboard(charSet); + keyboardNum = textInput.generateKeyboard([["1","2","3","4","5","6","7","8","9"],["0"],"del", "ok"]); } function newTask(initialText) { initialText = initialText || ""; - textInput.input({text: initialText, keyboardMain: keyboard}) + textInput.input({text: initialText, keyboardMain: keyboardAlpha}) .then(text => { const task = createTask(text) - incompleteTasks.unshift(task); - currentTaskRef = task; - g.clear(); - Bangle.drawWidgets(); - setMenu(task.getMenu()); + allTasks.unshift(task); + startTask(task); }) } -const taskSwipeControls = [ - createSwipeControl(2, "Menu", () => setMenu(m1)), - createSwipeControl(0, "New Task", newTask), - createSwipeControl(1, "Edit Task", () => newTask("Initial text")) // Placeholder -]; +function startTask(task) { + currentTaskRef = task; + g.clear(); + Bangle.drawWidgets(); + setMenu(getTaskMenu(task)); +} + +/** + * Mark the task as completed and then push it to the bottom of the list. + * @param task + */ +function completeTask(task) { + task.complete = true; + removeTask(task, allTasks); + allTasks.push(task); + setMenu(getTaskMenu(task)); +} + +function restartTask(task) { + task.complete = false; + removeTask(task, allTasks); + allTasks.unshift(task); + setMenu(getTaskMenu(task)) +} + +function removeTask(task, list) { + const taskIndex = list.findIndex((item) => item === task); + if (taskIndex !== -1) { + list.splice(taskIndex, 1); + } +} + + +const SWIPE = { + LEFT: 2, RIGHT: 0, UP: 3, DOWN: 1, +} function createTask(text) { const incrementalBackoffSet = [0.5, 1, 2, 4, 8, 16, 32]; - const getMenu = () => createMenu({ - items: [{text}], spaceAround: 0, spaceBetween: 0, swipeControls: taskSwipeControls - }); return { text, affirmCount : 0, @@ -280,11 +306,51 @@ function createTask(text) { interval : 30, backoffIndex : 1, incrementalBackoffSet, - getMenu + complete : false }; } -const m1 = createMenu({ +function getTaskMenu(task) { + const taskSwipeControls = [ + createSwipeControl(SWIPE.LEFT, "Menu", () => setMenu(m1)), createSwipeControl(SWIPE.RIGHT, "New Task", newTask), + ]; + const items = []; + if (task.complete) { + taskSwipeControls.push(createSwipeControl(SWIPE.UP, "Restart", () => restartTask(task))); + items.push({text: task.text + " completed!", size: 1}); + const nextTask = getNextTask(task, allTasks); + if (nextTask) { + items.push({text: "next task: " + nextTask.text, size: 2, callback: () => startTask(nextTask)}) + } else { + items.push({ + text: "Affirmed: " + task.affirmCount + "\nDistracted: " + task.distractCount + "\nUnresponsive: " + task.unresponsiveCount, + size: 3 + }); + } + } else { + items.push({text: task.text}) + taskSwipeControls.push(createSwipeControl(SWIPE.DOWN, "Complete", () => completeTask(task))) + } + return createMenu({ + items, spaceAround: 0, spaceBetween: 0, swipeControls: taskSwipeControls + }); +} + +function getNextTask(task, list) { + const activeList = list.filter(x => (!x.complete || x === task)); + const thisTaskPosition = activeList.findIndex(t => t === task); + let nextTask = activeList[0]; + if (thisTaskPosition !== -1 && activeList[thisTaskPosition + 1]) { + nextTask = activeList[thisTaskPosition + 1]; + } + return nextTask === task ? undefined : nextTask; +} + +function showTaskList(list) { + +} + +const mainMenu = createMenu({ title : "Working Memory", items: [ {text: "New Task", size: 2, callback: newTask}, { text: "Manage", size: 1, callback: () => console.log("SETTINGS") @@ -292,4 +358,4 @@ const m1 = createMenu({ ], isHorizontal: false }); -setMenu(m1); +setMenu(mainMenu); From 94077313d1c92b8728a6caf1c46b676762dc4ca0 Mon Sep 17 00:00:00 2001 From: Philip Andresen Date: Mon, 12 Jun 2023 15:32:49 -0400 Subject: [PATCH 04/11] Add showMenu bug workaround - add initial buzz --- apps/remindr/app.js | 53 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/apps/remindr/app.js b/apps/remindr/app.js index 59c7b9128..d70dd9858 100644 --- a/apps/remindr/app.js +++ b/apps/remindr/app.js @@ -8,6 +8,7 @@ Bangle.drawWidgets(); let currentTaskRef; const allTasks = []; +let taskTimeout; function createButton(x, y, w, h, text, callback) { text = text || ""; @@ -245,8 +246,8 @@ function setMenu(menu) { let keyboardAlpha, keyboardNum; if (textInput.generateKeyboard) { const charSet = textInput.createCharSet("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ["spc", "ok", "del"]); - keyboardAlpha = textInput.generateKeyboard(charSet); - keyboardNum = textInput.generateKeyboard([["1","2","3","4","5","6","7","8","9"],["0"],"del", "ok"]); + keyboardAlpha = textInput.generateKeyboard(charSet); + // keyboardNum = textInput.generateKeyboard([["1", "2", "3", "4", "5", "6", "7", "8", "9"], ["0"], "del", "ok"]); } function newTask(initialText) { @@ -261,6 +262,7 @@ function newTask(initialText) { function startTask(task) { currentTaskRef = task; + taskTimeout = setTimeout(() => Bangle.buzz(100, 1), 3000); g.clear(); Bangle.drawWidgets(); setMenu(getTaskMenu(task)); @@ -312,7 +314,7 @@ function createTask(text) { function getTaskMenu(task) { const taskSwipeControls = [ - createSwipeControl(SWIPE.LEFT, "Menu", () => setMenu(m1)), createSwipeControl(SWIPE.RIGHT, "New Task", newTask), + createSwipeControl(SWIPE.LEFT, "Menu", () => setMenu(mainMenu)), createSwipeControl(SWIPE.RIGHT, "New Task", newTask), ]; const items = []; if (task.complete) { @@ -346,14 +348,53 @@ function getNextTask(task, list) { return nextTask === task ? undefined : nextTask; } -function showTaskList(list) { - +/** + * This function is a workaround wrapper for a menu navigation bug. After 'onchange' the menu re-renders itself + * so to avoid graphical glitches we postpone whatever funciton we actually want by 5ms. + * @param fn The function you actually want to call + * @returns {function(): any} The same function wrapped in a setTimeout with a 5ms delay. + */ +function st5(fn) { + return () => setTimeout(fn, 5); +} + +function editTask(task, backFn) { + let editMenu = []; + editMenu.push({title: "Rename", onchange: st5(() => renameTask(task, () => editTask(task, backFn)))}); + if (task.complete) { + editMenu.push({title: "Start Task", onchange: st5(() => restartTask(task))}) + editMenu.push({title: "View Task", onchange: st5(() => startTask(task))}) + } else { + editMenu.push({title: "Resume Task", onchange: st5(() => startTask(task))}) + } + editMenu[""] = {title: task.text, back: backFn}; + E.showMenu(editMenu); +} + +function renameTask(task, backFn) { + return textInput.input({text: task.text, keyboardMain: keyboardAlpha}) + .then(text => { + task.text = text + backFn(); + }) +} + +function showTaskList(list, backFn) { + let taskMenu = []; + taskMenu = taskMenu.concat(list.map(task => { + return { + // Workaround - navigation has phantom buttons rendered with E.showMenu unless you delay slightly. + title: task.text, onchange: st5(() => editTask(task, () => showTaskList(list, backFn))) + } + })) + taskMenu[""] = {title: "Tasks", back: backFn}; + E.showMenu(taskMenu); } const mainMenu = createMenu({ title : "Working Memory", items: [ {text: "New Task", size: 2, callback: newTask}, { - text: "Manage", size: 1, callback: () => console.log("SETTINGS") + text: "Manage", size: 1, callback: () => showTaskList(allTasks, () => setMenu(mainMenu)) } ], isHorizontal: false }); From 3682ee4e79492ed071260bcde00955c7614a4cc0 Mon Sep 17 00:00:00 2001 From: Philip Andresen Date: Mon, 12 Jun 2023 19:26:02 -0400 Subject: [PATCH 05/11] Implement nudges and affirmations --- apps/remindr/app.js | 80 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/apps/remindr/app.js b/apps/remindr/app.js index d70dd9858..f2003f055 100644 --- a/apps/remindr/app.js +++ b/apps/remindr/app.js @@ -6,9 +6,24 @@ E.showMessage("Loading ... "); Bangle.loadWidgets(); Bangle.drawWidgets(); -let currentTaskRef; -const allTasks = []; -let taskTimeout; +const allTasks = []; +const nudgeManager = { + activeTask : null, taskTimeout: null, responseTimeout: null, interrupt: () => { + if (this.taskTimeout) clearTimeout(this.taskTimeout); + if (this.responseTimeout) clearTimeout(this.responseTimeout); + this.activeTask = null; + }, queueNudge : (task, nudgeFn) => { + if (this.responseTimeout) clearTimeout(this.responseTimeout); + if (this.taskTimeout) clearTimeout(this.taskTimeout); + this.activeTask = task; + const time = task.incrementalBackoffSet[task.backoffIndex] * task.interval * 1000; + this.taskTimeout = setTimeout(nudgeFn, time); + }, queueResponseTimeout: (defaultFn) => { + // This timeout shouldn't be set if we've queued a response timeout, but we clear it anyway. + if (this.taskTimeout) clearTimeout(this.taskTimeout); + this.responseTimeout = setTimeout(defaultFn, 15000); + }, +} function createButton(x, y, w, h, text, callback) { text = text || ""; @@ -261,18 +276,62 @@ function newTask(initialText) { } function startTask(task) { - currentTaskRef = task; - taskTimeout = setTimeout(() => Bangle.buzz(100, 1), 3000); + nudgeManager.queueNudge(task, () => nudge(task)); g.clear(); Bangle.drawWidgets(); setMenu(getTaskMenu(task)); } +function nudge(task) { + Bangle.buzz(250, 1) + .then(() => { + Bangle.setLocked(false); + Bangle.setLCDPower(true); + }); + const nudgeMenu = createMenu({ + title : "Are you on task?", titleFont: "6x8", items: [ + {text: task.text, size: 1}, {text: "On Task", size: 2, callback: () => affirmOnTask(task)}, { + text: "Distracted", size: 2, callback: () => affirmDistracted(task) + } + ], isHorizontal: false + }); + setMenu(nudgeMenu); + nudgeManager.queueResponseTimeout(() => concludeUnresponsive(task)); +} + +function affirmOnTask(task) { + task.affirmCount++; + task.backoffIndex = Math.min(task.incrementalBackoffSet.length - 1, task.backoffIndex + 1); + showTempMessage("Great job!", "On Task!", () => startTask(task)); +} + +function affirmDistracted(task) { + task.distractCount++; + task.backoffIndex = Math.max(0, task.backoffIndex - 1); + showTempMessage("Don't worry! You've got this!", "Distracted!", () => startTask(task)); +} + +function concludeUnresponsive(task) { + Bangle.buzz(250, 1).then(() => Bangle.setLCDPower(true)); + task.unresponsiveCount++; + task.backoffIndex = Math.max(0, task.backoffIndex - 1); + nudgeManager.queueResponseTimeout(() => concludeUnresponsive(task)) +} + +function showTempMessage(text, title, thenFn) { + E.showMessage(text,{title}); + setTimeout(() => { + Bangle.setLocked(true); + thenFn(); + }, 1500); +} + /** * Mark the task as completed and then push it to the bottom of the list. * @param task */ function completeTask(task) { + nudgeManager.interrupt(); task.complete = true; removeTask(task, allTasks); allTasks.push(task); @@ -283,7 +342,7 @@ function restartTask(task) { task.complete = false; removeTask(task, allTasks); allTasks.unshift(task); - setMenu(getTaskMenu(task)) + startTask(task); } function removeTask(task, list) { @@ -314,7 +373,8 @@ function createTask(text) { function getTaskMenu(task) { const taskSwipeControls = [ - createSwipeControl(SWIPE.LEFT, "Menu", () => setMenu(mainMenu)), createSwipeControl(SWIPE.RIGHT, "New Task", newTask), + createSwipeControl(SWIPE.LEFT, "Menu", () => setMenu(mainMenu)), + createSwipeControl(SWIPE.RIGHT, "New Task", newTask), ]; const items = []; if (task.complete) { @@ -367,7 +427,7 @@ function editTask(task, backFn) { } else { editMenu.push({title: "Resume Task", onchange: st5(() => startTask(task))}) } - editMenu[""] = {title: task.text, back: backFn}; + editMenu[""] = {title: task.text, back: backFn}; E.showMenu(editMenu); } @@ -381,13 +441,13 @@ function renameTask(task, backFn) { function showTaskList(list, backFn) { let taskMenu = []; - taskMenu = taskMenu.concat(list.map(task => { + taskMenu = taskMenu.concat(list.map(task => { return { // Workaround - navigation has phantom buttons rendered with E.showMenu unless you delay slightly. title: task.text, onchange: st5(() => editTask(task, () => showTaskList(list, backFn))) } })) - taskMenu[""] = {title: "Tasks", back: backFn}; + taskMenu[""] = {title: "Tasks", back: backFn}; E.showMenu(taskMenu); } From a398522e762352b417362a304953adf3adfea05f Mon Sep 17 00:00:00 2001 From: Philip Andresen Date: Mon, 12 Jun 2023 22:25:18 -0400 Subject: [PATCH 06/11] Tweak layouts and add data for release --- apps/wrkmem/Changelog | 1 + apps/wrkmem/README.md | 61 +++++++++++++++++++++++++++++ apps/wrkmem/app-icon.js | 1 + apps/{remindr => wrkmem}/app.js | 67 +++++++++++++++++++++++++------- apps/wrkmem/icon.png | Bin 0 -> 1244 bytes apps/wrkmem/metadata.json | 26 +++++++++++++ apps/wrkmem/screenshot.png | Bin 0 -> 3139 bytes apps/wrkmem/screenshot2.png | Bin 0 -> 2887 bytes apps/wrkmem/screenshot3.png | Bin 0 -> 4171 bytes apps/wrkmem/screenshot4.png | Bin 0 -> 3623 bytes apps/wrkmem/screenshot5.png | Bin 0 -> 2649 bytes apps/wrkmem/screenshot6.png | Bin 0 -> 2937 bytes 12 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 apps/wrkmem/Changelog create mode 100644 apps/wrkmem/README.md create mode 100644 apps/wrkmem/app-icon.js rename apps/{remindr => wrkmem}/app.js (88%) create mode 100644 apps/wrkmem/icon.png create mode 100644 apps/wrkmem/metadata.json create mode 100644 apps/wrkmem/screenshot.png create mode 100644 apps/wrkmem/screenshot2.png create mode 100644 apps/wrkmem/screenshot3.png create mode 100644 apps/wrkmem/screenshot4.png create mode 100644 apps/wrkmem/screenshot5.png create mode 100644 apps/wrkmem/screenshot6.png diff --git a/apps/wrkmem/Changelog b/apps/wrkmem/Changelog new file mode 100644 index 000000000..55caa0461 --- /dev/null +++ b/apps/wrkmem/Changelog @@ -0,0 +1 @@ +1.00: Implement Working Memory Helper app \ No newline at end of file diff --git a/apps/wrkmem/README.md b/apps/wrkmem/README.md new file mode 100644 index 000000000..3d57dff9a --- /dev/null +++ b/apps/wrkmem/README.md @@ -0,0 +1,61 @@ +# Working Memory Helper +Human brains keep track of what they are doing in a conceptual space known as "working memory". Older adults and people +of all ages with ADHD often struggle to maintain information in their working memories, causing them to forget what +they were doing only moments after deciding to do it. One excellent way to combat this symptom is to externalize your +working memory. + +This app doesn't completely externalize and replace working memory, but it does act as a prosthesis for the task +management aspect of working memory. The workflow looks something like this: + +1. Decide to do something. (If you can't get this far on your own, this app is not gonna help.) +2. Immediately enter a brief prompt in the app as a "task". For example, if you were going to take out the trash, +you might write "Trash". If you were going to take your car to the mechanic, you might write "car", or "mechanic". It +doesn't have to remind you what you were doing a week from now, only a minute or so, so it can be very simple / brief. +3. Thirty seconds after you enter the task into the app, your device will vibrate and ask you if you are on task, or if +you got distracted. + 1. If you are on task, hit "On task" and the app will wait a little longer before reminding you again. + 2. If you got distracted, hit "distracted" and the app will remind you a little sooner next time. +4. Continue getting reminders from your watch at various intervals until you complete the task, then tell the app the +task is complete. Repeat this process for every single thing you do until you die, basically. + +![screenshot](screenshot.png) ![screenshot](screenshot2.png) ![screenshot](screenshot3.png) ![screenshot](screenshot4.png) +![screenshot](screenshot5.png) ![screenshot](screenshot6.png) + +## Requirements +You must have some kind of keyboard library available in order to enter task descriptions on your device. This app is +only supported on BangleJS2 + +## Styling +This app attempts to match whatever theme your Bangle watch is using. Styling options are not currently available +beyond that, but tweaking some things will eventually be possible, like the size and presence of swipe hints, whether +or not task text is outlined, etc. + +## Task settings +You can edit the settings of any individual task. You can rename the task, restart (un-complete) the task, or change +some of the reminder cadence settings. As far as cadence, there are a couple that warrante explanation: + +#### Interval +This is the base reminder interval for your task. If it is 30, your first reminder will be after 30 seconds. + +#### Incremental Backoff +Incremental backoff is a strategy for timing the reminder notifications you get based on how well you stay on task. +Each time you affirm that you are "on task", incremental backoff means it will wait longer before reminding you again. +Similarly each time you affirm that you are "distracted" the incremental backoff will wait less time before reminding +you again. The exact intervals are multiples of the base interval. For a task with a base interval of 30 seconds, the +second reminder would be after 60 seconds. The third after 120 seconds, etc. Then if you got distracted it would go +back to 60, then 30, then 15. Typically the interval will never go below 1/2 of your base interval. + +If you disable Incremental Backoff, you will be reminded once every base interval no matter what you do. This can be +handy if you are having trouble staying on task when the intervals get too long with incremental backoff. + +## Controls +A large focus of this app was making clear affordances for the user interface. Anything that can be pressed should look +like a button, however you may notice some small arrows and text on the sides / top / bottom of the screen in some +cases. These hints are there to tell you that you can swipe across the screen to perform additional actions. +Swipe your finger anywhere on the screen in the direction the arrow is pointing to use the listed function. + +## Known issues +The clock is not super-duper accurate because it only updates when the screen refreshes (which can be 30 seconds or 5 +minutes apart). I put it on there, though, because it is more useful to be there and lagging by 30+ seconds than to be +not there at all. I plan to fix this problem eventually but it's secondary to the main functions of the app at the +moment. \ No newline at end of file diff --git a/apps/wrkmem/app-icon.js b/apps/wrkmem/app-icon.js new file mode 100644 index 000000000..edc5d96e7 --- /dev/null +++ b/apps/wrkmem/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwgYJGgVJkmQDZt/////wRfgVP8g1OiQRByQRNyQRCoAMHgP8wEAk5GBAAPJgET/IREh//yVJCAYABkmf/8gCIc///yNIQAD/gCB8ARFAAQmBkmTA4YREGoIvCJQIABHYY1EhIHB/DFDCgItB/IREgM//x3Byg2BiUAggRHLINAgsvFAOkC4JrGCIKtBmZCC/2IR4IRFLILRBCIf/6MAgf/8gREWYUz/J8CCIMAv4REAoIRBg1CpMmv/6GQNPDoQXGHAMA54RCF4Y7HAAOvCIZTCCIRfECIp3BCLpHDCJcJNYJ9CCJdSpaPDCJUDe4LFCCJ2odIIRN9USeogRK9C/DCLDFDCIzFEdIoRHfYwFDsmESodPCIgXFqVECId/CIpNEGohTFL4WSCI8T/8kCIvyVoIRFz7jFgE//1ICIsE//5CAkAg4IBDQJrCgLbB5ARFDQI+BwEJCgNJfwIsBAAsfCQOSpMkyYFB+QQGgECv4MBAAf8YQYAFhIRFXggAGk4QD5J6FAAxHCySVBAHQ=")) \ No newline at end of file diff --git a/apps/remindr/app.js b/apps/wrkmem/app.js similarity index 88% rename from apps/remindr/app.js rename to apps/wrkmem/app.js index f2003f055..4548f4f8d 100644 --- a/apps/remindr/app.js +++ b/apps/wrkmem/app.js @@ -6,7 +6,24 @@ E.showMessage("Loading ... "); Bangle.loadWidgets(); Bangle.drawWidgets(); -const allTasks = []; +const localTaskFile = "wrkmem.json"; +let savedData = require("Storage") +.readJSON(localTaskFile, true); +if (!savedData) { + savedData = { + tasks: [], keyboardAlpha: undefined + + } +} + +let currentMenu; + +function save() { + require("Storage") + .writeJSON("wrkmem.json", savedData); +} + +const allTasks = savedData.tasks; const nudgeManager = { activeTask : null, taskTimeout: null, responseTimeout: null, interrupt: () => { if (this.taskTimeout) clearTimeout(this.taskTimeout); @@ -16,7 +33,8 @@ const nudgeManager = { if (this.responseTimeout) clearTimeout(this.responseTimeout); if (this.taskTimeout) clearTimeout(this.taskTimeout); this.activeTask = task; - const time = task.incrementalBackoffSet[task.backoffIndex] * task.interval * 1000; + const backoffIndex = task.useBackoff ? task.backoffIndex : 1; + const time = task.incrementalBackoffSet[backoffIndex] * task.interval * 1000; this.taskTimeout = setTimeout(nudgeFn, time); }, queueResponseTimeout: (defaultFn) => { // This timeout shouldn't be set if we've queued a response timeout, but we clear it anyway. @@ -254,23 +272,25 @@ function createMenu(options) { function setMenu(menu) { g.clearRect(Bangle.appRect); g.reset(); + currentMenu = menu; menu.render(); menu.setUI(); } -let keyboardAlpha, keyboardNum; +let keyboardAlpha; if (textInput.generateKeyboard) { const charSet = textInput.createCharSet("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ["spc", "ok", "del"]); - keyboardAlpha = textInput.generateKeyboard(charSet); - // keyboardNum = textInput.generateKeyboard([["1", "2", "3", "4", "5", "6", "7", "8", "9"], ["0"], "del", "ok"]); + keyboardAlpha = textInput.generateKeyboard(charSet) } function newTask(initialText) { + nudgeManager.interrupt(); initialText = initialText || ""; textInput.input({text: initialText, keyboardMain: keyboardAlpha}) .then(text => { const task = createTask(text) allTasks.unshift(task); + save(); startTask(task); }) } @@ -290,8 +310,8 @@ function nudge(task) { }); const nudgeMenu = createMenu({ title : "Are you on task?", titleFont: "6x8", items: [ - {text: task.text, size: 1}, {text: "On Task", size: 2, callback: () => affirmOnTask(task)}, { - text: "Distracted", size: 2, callback: () => affirmDistracted(task) + {text: task.text, size: 1}, {text: "On Task", size: 1, callback: () => affirmOnTask(task)}, { + text: "Distracted", size: 1, callback: () => affirmDistracted(task) } ], isHorizontal: false }); @@ -312,14 +332,15 @@ function affirmDistracted(task) { } function concludeUnresponsive(task) { - Bangle.buzz(250, 1).then(() => Bangle.setLCDPower(true)); + Bangle.buzz(250, 1) + .then(() => Bangle.setLCDPower(true)); task.unresponsiveCount++; task.backoffIndex = Math.max(0, task.backoffIndex - 1); nudgeManager.queueResponseTimeout(() => concludeUnresponsive(task)) } function showTempMessage(text, title, thenFn) { - E.showMessage(text,{title}); + E.showMessage(text, {title}); setTimeout(() => { Bangle.setLocked(true); thenFn(); @@ -335,6 +356,7 @@ function completeTask(task) { task.complete = true; removeTask(task, allTasks); allTasks.push(task); + save(); setMenu(getTaskMenu(task)); } @@ -342,6 +364,7 @@ function restartTask(task) { task.complete = false; removeTask(task, allTasks); allTasks.unshift(task); + save(); startTask(task); } @@ -367,18 +390,27 @@ function createTask(text) { interval : 30, backoffIndex : 1, incrementalBackoffSet, - complete : false + complete : false, + useBackoff: true }; } function getTaskMenu(task) { + const d = new Date(); + const h = d.getHours(), m = d.getMinutes(); + const time = h + ":" + m.toString().padStart(2,0); const taskSwipeControls = [ - createSwipeControl(SWIPE.LEFT, "Menu", () => setMenu(mainMenu)), - createSwipeControl(SWIPE.RIGHT, "New Task", newTask), + createSwipeControl(SWIPE.LEFT, "Menu", () => { + setMenu(mainMenu); + nudgeManager.interrupt(); + }), createSwipeControl(SWIPE.RIGHT, "New Task", newTask), ]; const items = []; if (task.complete) { taskSwipeControls.push(createSwipeControl(SWIPE.UP, "Restart", () => restartTask(task))); + taskSwipeControls.push(createSwipeControl(SWIPE.DOWN, + "Task List", + () => showTaskList(allTasks, () => startTask(task)))); items.push({text: task.text + " completed!", size: 1}); const nextTask = getNextTask(task, allTasks); if (nextTask) { @@ -390,11 +422,12 @@ function getTaskMenu(task) { }); } } else { - items.push({text: task.text}) - taskSwipeControls.push(createSwipeControl(SWIPE.DOWN, "Complete", () => completeTask(task))) + items.push({text: task.text, size: 2}) + taskSwipeControls.push(createSwipeControl(SWIPE.UP, "Complete", () => completeTask(task))) + taskSwipeControls.push(createSwipeControl(SWIPE.DOWN, "Edit Task", () => editTask(task, () => startTask(task)))) } return createMenu({ - items, spaceAround: 0, spaceBetween: 0, swipeControls: taskSwipeControls + items, spaceAround: 0, spaceBetween: 0, swipeControls: taskSwipeControls, title: time }); } @@ -419,6 +452,7 @@ function st5(fn) { } function editTask(task, backFn) { + nudgeManager.interrupt(); let editMenu = []; editMenu.push({title: "Rename", onchange: st5(() => renameTask(task, () => editTask(task, backFn)))}); if (task.complete) { @@ -427,6 +461,8 @@ function editTask(task, backFn) { } else { editMenu.push({title: "Resume Task", onchange: st5(() => startTask(task))}) } + editMenu.push({ title:"Interval", value: task.interval, min:10, step: 10, onchange: v => task.interval = v }) + editMenu.push({ title:"Incremental Backoff", value: !!task.useBackoff, onchange: v => task.useBackoff = v }) editMenu[""] = {title: task.text, back: backFn}; E.showMenu(editMenu); } @@ -435,6 +471,7 @@ function renameTask(task, backFn) { return textInput.input({text: task.text, keyboardMain: keyboardAlpha}) .then(text => { task.text = text + save(); backFn(); }) } diff --git a/apps/wrkmem/icon.png b/apps/wrkmem/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..23e1df52329f793df2cf7a0f843662c517a69fa6 GIT binary patch literal 1244 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sjKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WMyDrW(e>JaRrJqsQqX7ulS#V;s1XiF$zXQU<8E#<4n)*K<{!EctjR6Fz_7) zVaDV6D^h@h5+$w?CBgY=CFO}lsSE{)nRz98d8s7|CVGZ?rYY_bz%0k;=;`7Z;^BRE zisz)m3IZ&x7ysY4*(>s;jiv8H;wOW)S6-aiIq}+32g**{I&NUjWjZKkb+)#?P{nQ^ zM?0Ij;vR`R7g*Qw1iDOLsHnSh*%ujQ@3u1;E7l9PWH6m=xZ~-duz)ki#Wba*_|n8@ z4AzdkRmIaKU+tYd(L^J&pe0&v1>We|3VVUC`TX zGybV8wilcV?g#0wsZVPXY-zXS(fBrH%f46VCu;nf9xw1|-2&&r0~`SwXWSk<{B+8x o@~v>-!eqsI$Nu#Szs|>8mv<`SV#r8Owge?QPgg&ebxsLQ05y)pQ~&?~ literal 0 HcmV?d00001 diff --git a/apps/wrkmem/metadata.json b/apps/wrkmem/metadata.json new file mode 100644 index 000000000..040fbc750 --- /dev/null +++ b/apps/wrkmem/metadata.json @@ -0,0 +1,26 @@ +{ + "id" : "wrkmem", + "name" : "Working Memory Helper", + "version" : "1.00", + "description" : "Externalize your working memory to help stay on task.", + "dependencies" : {"textinput": "type"}, + "icon" : "icon.png", + "type" : "app", + "tags" : "tool", + "supports" : ["BANGLEJS2"], + "screenshots" : [ + {"url": "screenshot.png"}, + {"url": "screenshot2.png"}, + {"url": "screenshot3.png"}, + {"url": "screenshot4.png"}, + {"url": "screenshot5.png"}, + {"url": "screenshot6.png"} + ], + "readme" : "README.md", + "allow_emulator": false, + "storage" : [ + {"name": "wrkmem.app.js", "url": "app.js"}, + {"name": "wrkmem.img", "url": "app-icon.js", "evaluate": true} + ], + "data" : [{"name": "wrkmem.json"}] +} \ No newline at end of file diff --git a/apps/wrkmem/screenshot.png b/apps/wrkmem/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..7145bc8a781f3eebdc0aab864062affafc0a16cc GIT binary patch literal 3139 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 Not2{{$=vJFe*s!}{DlAj literal 0 HcmV?d00001 diff --git a/apps/wrkmem/screenshot2.png b/apps/wrkmem/screenshot2.png new file mode 100644 index 0000000000000000000000000000000000000000..3a61529e338c509a107e6fa1c8acb9b26eb2e55d GIT binary patch literal 2887 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 DVhKwJ literal 0 HcmV?d00001 diff --git a/apps/wrkmem/screenshot3.png b/apps/wrkmem/screenshot3.png new file mode 100644 index 0000000000000000000000000000000000000000..30776d33207c44d001cf4501da1aa75659837d1c GIT binary patch literal 4171 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 V{!5g%wsQag002ovPDHLkV1lDnJp}*& literal 0 HcmV?d00001 diff --git a/apps/wrkmem/screenshot4.png b/apps/wrkmem/screenshot4.png new file mode 100644 index 0000000000000000000000000000000000000000..582c2c92b85fdf615772c191d9223b7db0522ae1 GIT binary patch literal 3623 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 literal 0 HcmV?d00001 diff --git a/apps/wrkmem/screenshot5.png b/apps/wrkmem/screenshot5.png new file mode 100644 index 0000000000000000000000000000000000000000..aa9165e38d06781fd0d314fbe09d26281513c6ad GIT binary patch literal 2649 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 literal 0 HcmV?d00001 diff --git a/apps/wrkmem/screenshot6.png b/apps/wrkmem/screenshot6.png new file mode 100644 index 0000000000000000000000000000000000000000..fd3e1a36b501370e6feaee86d6e77626d3b7c9a0 GIT binary patch literal 2937 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 Date: Tue, 13 Jun 2023 12:45:01 -0400 Subject: [PATCH 07/11] Add settings, display options, back functions --- apps/wrkmem/app.js | 100 +++++++++++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 39 deletions(-) diff --git a/apps/wrkmem/app.js b/apps/wrkmem/app.js index 4548f4f8d..8c83f7396 100644 --- a/apps/wrkmem/app.js +++ b/apps/wrkmem/app.js @@ -7,14 +7,12 @@ Bangle.loadWidgets(); Bangle.drawWidgets(); const localTaskFile = "wrkmem.json"; -let savedData = require("Storage") -.readJSON(localTaskFile, true); -if (!savedData) { - savedData = { - tasks: [], keyboardAlpha: undefined +const savedData = { + tasks: [], keyboardAlpha: undefined, settings: {textOutlines: true, noWordBreaks: true} +}; - } -} +Object.assign(savedData, require("Storage") +.readJSON(localTaskFile, true) || {}); let currentMenu; @@ -32,10 +30,10 @@ const nudgeManager = { }, queueNudge : (task, nudgeFn) => { if (this.responseTimeout) clearTimeout(this.responseTimeout); if (this.taskTimeout) clearTimeout(this.taskTimeout); - this.activeTask = task; + this.activeTask = task; const backoffIndex = task.useBackoff ? task.backoffIndex : 1; - const time = task.incrementalBackoffSet[backoffIndex] * task.interval * 1000; - this.taskTimeout = setTimeout(nudgeFn, time); + const time = task.incrementalBackoffSet[backoffIndex] * task.interval * 1000; + this.taskTimeout = setTimeout(nudgeFn, time); }, queueResponseTimeout: (defaultFn) => { // This timeout shouldn't be set if we've queued a response timeout, but we clear it anyway. if (this.taskTimeout) clearTimeout(this.taskTimeout); @@ -94,18 +92,20 @@ function drawButton(button) { .wrapString(button.text, textMaxWidth) .filter(t => !!t) .join("\n"); - g.setFontAlign(0, 0) - .setColor(textOutlineCol) - .drawString(wrapText, button.x + button.w / 2 + 1, button.y + button.h / 2 - 1, false); - g.setFontAlign(0, 0) - .setColor(textOutlineCol) - .drawString(wrapText, button.x + button.w / 2 - 1, button.y + button.h / 2 - 1, false); - g.setFontAlign(0, 0) - .setColor(textOutlineCol) - .drawString(wrapText, button.x + button.w / 2 - 1, button.y + button.h / 2 + 1, false); - g.setFontAlign(0, 0) - .setColor(textOutlineCol) - .drawString(wrapText, button.x + button.w / 2 + 1, button.y + button.h / 2 + 1, false); + if (savedData.settings.textOutlines) { + g.setFontAlign(0, 0) + .setColor(textOutlineCol) + .drawString(wrapText, button.x + button.w / 2 + 1, button.y + button.h / 2 - 1, false); + g.setFontAlign(0, 0) + .setColor(textOutlineCol) + .drawString(wrapText, button.x + button.w / 2 - 1, button.y + button.h / 2 - 1, false); + g.setFontAlign(0, 0) + .setColor(textOutlineCol) + .drawString(wrapText, button.x + button.w / 2 - 1, button.y + button.h / 2 + 1, false); + g.setFontAlign(0, 0) + .setColor(textOutlineCol) + .drawString(wrapText, button.x + button.w / 2 + 1, button.y + button.h / 2 + 1, false); + } g.setFontAlign(0, 0) .setColor(textCol) .drawString(wrapText, button.x + button.w / 2, button.y + button.h / 2, false); @@ -122,7 +122,7 @@ function getBestFontForButton(button) { const vectorRatio = sampleMetric.height / sampleMetric.width; // Effective height helps us handle tall skinny buttons, since text is usually horizontal. let effectiveHeight = Math.min(button.h, button.w); - if (!button.text.includes(" ")) { + if (!button.text.includes(" ") && savedData.settings.noWordBreaks) { effectiveHeight = effectiveHeight / vectorRatio } const buttonArea = button.w * effectiveHeight; @@ -263,13 +263,15 @@ function createMenu(options) { const touchFunc = (button, xy) => buttons.forEach(b => b.onTouch && b.onTouch(button, xy)); const swipeFunc = (LR, UD) => swipeControls.forEach(s => s.onSwipe(LR, UD)); + const btnFunc = options.backFn; return { - buttons, render, setUI: () => Bangle.setUI({mode: "custom", touch: touchFunc, swipe: swipeFunc}) + buttons, render, setUI: () => Bangle.setUI({mode: "custom", touch: touchFunc, swipe: swipeFunc, btn: btnFunc}) }; } function setMenu(menu) { + save(); g.clearRect(Bangle.appRect); g.reset(); currentMenu = menu; @@ -299,7 +301,11 @@ function startTask(task) { nudgeManager.queueNudge(task, () => nudge(task)); g.clear(); Bangle.drawWidgets(); - setMenu(getTaskMenu(task)); + const onPressBack = () => { + nudgeManager.interrupt(); + setMenu(mainMenu) + } + setMenu(getTaskMenu(task, onPressBack)); } function nudge(task) { @@ -391,14 +397,15 @@ function createTask(text) { backoffIndex : 1, incrementalBackoffSet, complete : false, - useBackoff: true + useBackoff : true }; } -function getTaskMenu(task) { - const d = new Date(); - const h = d.getHours(), m = d.getMinutes(); - const time = h + ":" + m.toString().padStart(2,0); +function getTaskMenu(task, backFn) { + const d = new Date(); + const h = d.getHours(), m = d.getMinutes(); + const time = h + ":" + m.toString() + .padStart(2, 0); const taskSwipeControls = [ createSwipeControl(SWIPE.LEFT, "Menu", () => { setMenu(mainMenu); @@ -427,7 +434,7 @@ function getTaskMenu(task) { taskSwipeControls.push(createSwipeControl(SWIPE.DOWN, "Edit Task", () => editTask(task, () => startTask(task)))) } return createMenu({ - items, spaceAround: 0, spaceBetween: 0, swipeControls: taskSwipeControls, title: time + items, spaceAround: 0, spaceBetween: 0, swipeControls: taskSwipeControls, title: time, backFn }); } @@ -454,15 +461,17 @@ function st5(fn) { function editTask(task, backFn) { nudgeManager.interrupt(); let editMenu = []; - editMenu.push({title: "Rename", onchange: st5(() => renameTask(task, () => editTask(task, backFn)))}); if (task.complete) { - editMenu.push({title: "Start Task", onchange: st5(() => restartTask(task))}) - editMenu.push({title: "View Task", onchange: st5(() => startTask(task))}) + editMenu.push({title: "Start Task", onchange: st5(() => restartTask(task))}); + editMenu.push({title: "View Task", onchange: st5(() => startTask(task))}); } else { - editMenu.push({title: "Resume Task", onchange: st5(() => startTask(task))}) + editMenu.push({title: "Resume Task", onchange: st5(() => startTask(task))}); } - editMenu.push({ title:"Interval", value: task.interval, min:10, step: 10, onchange: v => task.interval = v }) - editMenu.push({ title:"Incremental Backoff", value: !!task.useBackoff, onchange: v => task.useBackoff = v }) + editMenu.push({title: "Rename", onchange: st5(() => renameTask(task, () => editTask(task, backFn)))}); + editMenu.push({title: "Interval", value: task.interval, min: 10, step: 10, onchange: v => task.interval = v}); + editMenu.push({title: "Incremental Backoff", value: !!task.useBackoff, onchange: v => task.useBackoff = v}); + editMenu.push({title: "Statistics:"}) + editMenu.push({title: "On Task: " + task.affirmCount}) editMenu[""] = {title: task.text, back: backFn}; E.showMenu(editMenu); } @@ -488,10 +497,23 @@ function showTaskList(list, backFn) { E.showMenu(taskMenu); } +function showSettingsMenu(backFn) { + const completeTasks = allTasks.filter(task => task.complete); + const incompleteTasks = allTasks.filter(task => !task.complete); + const settingsMenu = { + "" : {title: "Manage", back: backFn}, + "Pending Tasks" : () => showTaskList(incompleteTasks, () => showSettingsMenu(backFn)), + "Completed Tasks": () => showTaskList(completeTasks, () => showSettingsMenu(backFn)), + "Text Outlines" : {value: savedData.settings.textOutlines, onchange: v => savedData.settings.textOutlines = v}, + "No Word Breaks" : {value: savedData.settings.noWordBreaks, onchange: v => savedData.settings.noWordBreaks = v} + } + E.showMenu(settingsMenu); +} + const mainMenu = createMenu({ title : "Working Memory", items: [ - {text: "New Task", size: 2, callback: newTask}, { - text: "Manage", size: 1, callback: () => showTaskList(allTasks, () => setMenu(mainMenu)) + {text: "New Task", size: 2, callback: () => newTask("")}, { + text: "Manage", size: 1, callback: () => showSettingsMenu(() => setMenu(mainMenu)) } ], isHorizontal: false }); From 52776eff7801378eea7588c611d1c37ea164cc12 Mon Sep 17 00:00:00 2001 From: Philip Andresen Date: Tue, 13 Jun 2023 13:19:12 -0400 Subject: [PATCH 08/11] Change Keyboard, fix a but or two, allow delete --- apps/wrkmem/app.js | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/wrkmem/app.js b/apps/wrkmem/app.js index 8c83f7396..80414e48c 100644 --- a/apps/wrkmem/app.js +++ b/apps/wrkmem/app.js @@ -281,8 +281,8 @@ function setMenu(menu) { let keyboardAlpha; if (textInput.generateKeyboard) { - const charSet = textInput.createCharSet("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ["spc", "ok", "del"]); - keyboardAlpha = textInput.generateKeyboard(charSet) + //const charSet = textInput.createCharSet("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ["spc", "ok", "del"]); + keyboardAlpha = textInput.generateKeyboard([["A", "B", "C", "J", "K", "L", "S", "T", "U"],["D", "E", "F", "M", "N", "O", "V", "W", "X"],["G", "H", "I", "P", "Q", "R", "Y", "Z"], "spc", "ok", "del"]) } function newTask(initialText) { @@ -348,7 +348,6 @@ function concludeUnresponsive(task) { function showTempMessage(text, title, thenFn) { E.showMessage(text, {title}); setTimeout(() => { - Bangle.setLocked(true); thenFn(); }, 1500); } @@ -417,7 +416,7 @@ function getTaskMenu(task, backFn) { taskSwipeControls.push(createSwipeControl(SWIPE.UP, "Restart", () => restartTask(task))); taskSwipeControls.push(createSwipeControl(SWIPE.DOWN, "Task List", - () => showTaskList(allTasks, () => startTask(task)))); + () => showTaskList(() => true, () => startTask(task)))); items.push({text: task.text + " completed!", size: 1}); const nextTask = getNextTask(task, allTasks); if (nextTask) { @@ -470,12 +469,30 @@ function editTask(task, backFn) { editMenu.push({title: "Rename", onchange: st5(() => renameTask(task, () => editTask(task, backFn)))}); editMenu.push({title: "Interval", value: task.interval, min: 10, step: 10, onchange: v => task.interval = v}); editMenu.push({title: "Incremental Backoff", value: !!task.useBackoff, onchange: v => task.useBackoff = v}); - editMenu.push({title: "Statistics:"}) - editMenu.push({title: "On Task: " + task.affirmCount}) + editMenu.push({title: "DELETE", onchange: st5(() => deleteTask(task, () => editTask(task, backFn), backFn))}); + editMenu.push({title: "Statistics:"}); + editMenu.push({title: "On Task: " + task.affirmCount}); + editMenu.push({title: "Distracted: " + task.distractCount}); + editMenu.push({title: "Unresponsive: " + task.unresponsiveCount}); editMenu[""] = {title: task.text, back: backFn}; E.showMenu(editMenu); } +function deleteTask(task, backFn, deleteBackFn) { + E.showPrompt("Delete " + task.text + "?") + .then(shouldDelete => { + if (shouldDelete) { + const foundIndex = allTasks.findIndex(t => t === task); + if (foundIndex !== -1) { + allTasks.splice(foundIndex, 1); + } + deleteBackFn(); + } else { + backFn(); + } + }); +} + function renameTask(task, backFn) { return textInput.input({text: task.text, keyboardMain: keyboardAlpha}) .then(text => { @@ -485,12 +502,13 @@ function renameTask(task, backFn) { }) } -function showTaskList(list, backFn) { +function showTaskList(filterFn, backFn) { let taskMenu = []; + const list = allTasks.filter(filterFn); taskMenu = taskMenu.concat(list.map(task => { return { // Workaround - navigation has phantom buttons rendered with E.showMenu unless you delay slightly. - title: task.text, onchange: st5(() => editTask(task, () => showTaskList(list, backFn))) + title: task.text, onchange: st5(() => editTask(task, () => showTaskList(filterFn, backFn))) } })) taskMenu[""] = {title: "Tasks", back: backFn}; @@ -502,8 +520,8 @@ function showSettingsMenu(backFn) { const incompleteTasks = allTasks.filter(task => !task.complete); const settingsMenu = { "" : {title: "Manage", back: backFn}, - "Pending Tasks" : () => showTaskList(incompleteTasks, () => showSettingsMenu(backFn)), - "Completed Tasks": () => showTaskList(completeTasks, () => showSettingsMenu(backFn)), + "Pending Tasks" : () => showTaskList(task => !task.complete, () => showSettingsMenu(backFn)), + "Completed Tasks": () => showTaskList(task => task.complete, () => showSettingsMenu(backFn)), "Text Outlines" : {value: savedData.settings.textOutlines, onchange: v => savedData.settings.textOutlines = v}, "No Word Breaks" : {value: savedData.settings.noWordBreaks, onchange: v => savedData.settings.noWordBreaks = v} } From 88d42643f60e4a52317d63e0c8d3a56e98438379 Mon Sep 17 00:00:00 2001 From: Philip Andresen Date: Tue, 13 Jun 2023 15:22:25 -0400 Subject: [PATCH 09/11] Finalize readme, iron out a couple bugs --- apps/wrkmem/README.md | 11 +- apps/wrkmem/app.js | 317 +++++++++++++++++++++++++++++++----------- 2 files changed, 243 insertions(+), 85 deletions(-) diff --git a/apps/wrkmem/README.md b/apps/wrkmem/README.md index 3d57dff9a..bad3914c9 100644 --- a/apps/wrkmem/README.md +++ b/apps/wrkmem/README.md @@ -26,18 +26,17 @@ You must have some kind of keyboard library available in order to enter task des only supported on BangleJS2 ## Styling -This app attempts to match whatever theme your Bangle watch is using. Styling options are not currently available -beyond that, but tweaking some things will eventually be possible, like the size and presence of swipe hints, whether -or not task text is outlined, etc. +This app attempts to match whatever theme your Bangle watch is using. You can also modify whether individual +words are wrapped and whether outlines are drawn on text. ## Task settings You can edit the settings of any individual task. You can rename the task, restart (un-complete) the task, or change -some of the reminder cadence settings. As far as cadence, there are a couple that warrante explanation: +some of the reminder cadence settings. As far as cadence, there are a couple that warrant explanation: -#### Interval +### Interval This is the base reminder interval for your task. If it is 30, your first reminder will be after 30 seconds. -#### Incremental Backoff +### Incremental Backoff Incremental backoff is a strategy for timing the reminder notifications you get based on how well you stay on task. Each time you affirm that you are "on task", incremental backoff means it will wait longer before reminding you again. Similarly each time you affirm that you are "distracted" the incremental backoff will wait less time before reminding diff --git a/apps/wrkmem/app.js b/apps/wrkmem/app.js index 80414e48c..3f283ca8a 100644 --- a/apps/wrkmem/app.js +++ b/apps/wrkmem/app.js @@ -1,7 +1,7 @@ const textInput = require("textinput"); -g.clearRect(Bangle.appRect); g.reset(); +g.clearRect(Bangle.appRect); E.showMessage("Loading ... "); Bangle.loadWidgets(); Bangle.drawWidgets(); @@ -16,12 +16,16 @@ Object.assign(savedData, require("Storage") let currentMenu; -function save() { - require("Storage") - .writeJSON("wrkmem.json", savedData); +const allTasks = savedData.tasks; +const SWIPE = { + LEFT: 2, RIGHT: 0, UP: 3, DOWN: 1, } -const allTasks = savedData.tasks; +/** + * A management object that helps us keep track of all our task timeouts. + * @type {{queueResponseTimeout: nudgeManager.queueResponseTimeout, taskTimeout: null, queueNudge: + * nudgeManager.queueNudge, interrupt: nudgeManager.interrupt, responseTimeout: null, activeTask: null}} + */ const nudgeManager = { activeTask : null, taskTimeout: null, responseTimeout: null, interrupt: () => { if (this.taskTimeout) clearTimeout(this.taskTimeout); @@ -41,6 +45,58 @@ const nudgeManager = { }, } +let keyboardAlpha, keyboardAlphaShift; + +if (textInput.generateKeyboard) { + //const charSet = textInput.createCharSet("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ["spc", "ok", "del"]); + keyboardAlpha = textInput.generateKeyboard([ + ["a", "b", "c", "j", "k", "l", "s", "t", "u"], + ["d", "e", "f", "m", "n", "o", "v", "w", "x"], + ["g", "h", "i", "p", "q", "r", "y", "z"], + "spc", + "ok", + "del" + ]); + keyboardAlphaShift = textInput.generateKeyboard([ + ["A", "B", "C", "J", "K", "L", "S", "T", "U"], + ["D", "E", "F", "M", "N", "O", "V", "W", "X"], + ["G", "H", "I", "P", "Q", "R", "Y", "Z"], + "spc", + "cncl", + "del" + ]) +} + +/** + * Save the data in 'savedData' to flash memory. + */ +function save() { + require("Storage") + .writeJSON("wrkmem.json", savedData); +} + +/** + * This function is a workaround wrapper for a menu navigation bug. After 'onchange' the menu re-renders itself + * so to avoid graphical glitches we postpone whatever funciton we actually want by 5ms. + * @param fn The function you actually want to call + * @returns {function(): any} The same function wrapped in a setTimeout with a 5ms delay. + */ +function st5(fn) { + return () => setTimeout(fn, 5); +} + +/** + * Given a position and set of dimensions, create a button object that represents a rectangle in space containing text + * and some associated functionality. + * @param x + * @param y + * @param w + * @param h + * @param text + * @param callback + * @returns {{padding: number, r: number, getDrawable: (function(*, *, *, *, *): {r, x, y, y2, x2}), w, x, h, y, text: + * string, onTouch: ((function(*, *): (*|undefined))|*)}} + */ function createButton(x, y, w, h, text, callback) { text = text || ""; const x2 = x + w; @@ -70,7 +126,10 @@ function createButton(x, y, w, h, text, callback) { }; } - +/** + * Given a button object, draw that button onto the screen. This includes the background, borders, effects, and text. + * @param button + */ function drawButton(button) { const textMaxWidth = button.w - 2 * button.padding; let textOutlineCol = g.theme.bgH; @@ -112,6 +171,12 @@ function drawButton(button) { g.reset(); } +/** + * Given a button object, determine what font would be best to display the button's text without breaching the + * dimensions of the button itself. Not perfectly at the moment, but serviceably. + * @param button + * @returns {string} + */ function getBestFontForButton(button) { const allowedFonts = ["12x20", "6x15", "6x8", "4x6"]; let stringMet = g.setFont("Vector:100") @@ -143,6 +208,14 @@ function getBestFontForButton(button) { return allowedFonts[i]; } +/** + * Given a rotation (0-3) and a label, create an object representing a swipe hint, complete with draw instructions, + * a handler, and the specified rotation. + * @param rot A number, preferably from the SWIPE enum. 0 = 0 degrees. 1 = 90 degrees. 2 = 180 degrees, etc. + * @param text The text to display on the swipe hint + * @param callback The function to be called when the corresponding direction is swiped. + * @returns {{rot, onSwipe: ((function(*, *): (*|undefined))|*), draw: draw}} + */ function createSwipeControl(rot, text, callback) { let draw = () => {}; let appRect = Bangle.appRect; @@ -176,6 +249,14 @@ function createSwipeControl(rot, text, callback) { return {draw, onSwipe, rot}; } +/** + * Given a position, rotation, text, and mirror option, draw a swipe hint on the screen. + * @param x The x position of the center of the swipe hint. + * @param y The y position of the center of the swipe hint. + * @param rot The SWIPE rotation enumerated value (0-3) indicating the direction. + * @param text The text to display in the hint. + * @param flip Whether or not to flip the direction of the swipe hint (left to right, up to down, etc). + */ function drawSwipeHint(x, y, rot, text, flip) { const tw = g.setFont("6x8") .stringWidth(text); @@ -197,6 +278,12 @@ function drawSwipeHint(x, y, rot, text, flip) { .drawImage(gRot, x, y, {rotate: Math.PI / 2 * rot}); } +/** + * Given a set of options, create a drawable / UI-able menu object that attempts to lay out buttons and swipe hints in + * a given space. Returns an object with both setUI and render functions. + * @param options + * @returns {{setUI: (function(): void), buttons: *[], render: render}} + */ function createMenu(options) { let width = options.width || Bangle.appRect.w; let height = options.height || Bangle.appRect.h; @@ -269,27 +356,34 @@ function createMenu(options) { }; } - +/** + * Given a menu object (a custom menu object created in this app, not an Espruino menu object) draw the menu to the + * screen and set the UI framework to the one appropriate to that menu. + * @param menu + */ function setMenu(menu) { save(); - g.clearRect(Bangle.appRect); g.reset(); + g.clearRect(Bangle.appRect); currentMenu = menu; menu.render(); menu.setUI(); + Bangle.drawWidgets(); } -let keyboardAlpha; -if (textInput.generateKeyboard) { - //const charSet = textInput.createCharSet("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ["spc", "ok", "del"]); - keyboardAlpha = textInput.generateKeyboard([["A", "B", "C", "J", "K", "L", "S", "T", "U"],["D", "E", "F", "M", "N", "O", "V", "W", "X"],["G", "H", "I", "P", "Q", "R", "Y", "Z"], "spc", "ok", "del"]) -} - +/** + * Create a new task with a given set of initial text. The user will be prompted with a keyboard to title the task. + * Once the task is created, begin that task. + * @param initialText + */ function newTask(initialText) { nudgeManager.interrupt(); initialText = initialText || ""; - textInput.input({text: initialText, keyboardMain: keyboardAlpha}) + textInput.input({text: initialText, keyboardMain: keyboardAlpha, keyboardShift: keyboardAlphaShift}) .then(text => { + if (!text) { + setMenu(mainMenu); + } const task = createTask(text) allTasks.unshift(task); save(); @@ -297,10 +391,13 @@ function newTask(initialText) { }) } +/** + * Begin the indicated task, taking the user to the corresponding menu / display screen and starting all relevant timers + * @param task + */ function startTask(task) { nudgeManager.queueNudge(task, () => nudge(task)); g.clear(); - Bangle.drawWidgets(); const onPressBack = () => { nudgeManager.interrupt(); setMenu(mainMenu) @@ -308,6 +405,11 @@ function startTask(task) { setMenu(getTaskMenu(task, onPressBack)); } +/** + * Remind the user of an ongoing task, prompting them to affirm that they are on task, distracted, or, after a set time + * period, unresponsive. + * @param task + */ function nudge(task) { Bangle.buzz(250, 1) .then(() => { @@ -325,18 +427,33 @@ function nudge(task) { nudgeManager.queueResponseTimeout(() => concludeUnresponsive(task)); } +/** + * Invoked when the user affirms that they are on task, increasing the affirmation count on the given task and + * advancing the incremental backoff counter. Congratulates the user for the response. + * @param task + */ function affirmOnTask(task) { task.affirmCount++; task.backoffIndex = Math.min(task.incrementalBackoffSet.length - 1, task.backoffIndex + 1); showTempMessage("Great job!", "On Task!", () => startTask(task)); } +/** + * Invoked when the user affirms that they were distracted, increasing the distraction count and lowering the + * incremental backoff counter. Encourages the user to keep trying. + * @param task + */ function affirmDistracted(task) { task.distractCount++; task.backoffIndex = Math.max(0, task.backoffIndex - 1); showTempMessage("Don't worry! You've got this!", "Distracted!", () => startTask(task)); } +/** + * Invoked when the user has not responded to an "on task?" prompt. Increments the unresponsive count and decrements + * the incremental backoff counter. + * @param task + */ function concludeUnresponsive(task) { Bangle.buzz(250, 1) .then(() => Bangle.setLCDPower(true)); @@ -345,6 +462,12 @@ function concludeUnresponsive(task) { nudgeManager.queueResponseTimeout(() => concludeUnresponsive(task)) } +/** + * Shows the user a message for a short period of time, then calls the "then function" + * @param text + * @param title + * @param thenFn + */ function showTempMessage(text, title, thenFn) { E.showMessage(text, {title}); setTimeout(() => { @@ -365,6 +488,10 @@ function completeTask(task) { setMenu(getTaskMenu(task)); } +/** + * Mark the task as not completed and then push it to the top of the list. + * @param task + */ function restartTask(task) { task.complete = false; removeTask(task, allTasks); @@ -373,6 +500,11 @@ function restartTask(task) { startTask(task); } +/** + * Remove the task from the given list. + * @param task + * @param list + */ function removeTask(task, list) { const taskIndex = list.findIndex((item) => item === task); if (taskIndex !== -1) { @@ -380,11 +512,12 @@ function removeTask(task, list) { } } - -const SWIPE = { - LEFT: 2, RIGHT: 0, UP: 3, DOWN: 1, -} - +/** + * Creates a task object given a set of text. + * @param text + * @returns {{distractCount: number, backoffIndex: number, incrementalBackoffSet: number[], affirmCount: number, + * unresponsiveCount: number, interval: number, text, complete: boolean, useBackoff: boolean}} + */ function createTask(text) { const incrementalBackoffSet = [0.5, 1, 2, 4, 8, 16, 32]; return { @@ -400,6 +533,73 @@ function createTask(text) { }; } +/** + * Shows a menu for editing the various properties of a given task. Also exposes the functions to start, restart, or + * delete the given task. + * @param task + * @param backFn + */ +function editTask(task, backFn) { + nudgeManager.interrupt(); + let editMenu = []; + if (task.complete) { + editMenu.push({title: "Start Task", onchange: st5(() => restartTask(task))}); + editMenu.push({title: "View Task", onchange: st5(() => startTask(task))}); + } else { + editMenu.push({title: "Resume Task", onchange: st5(() => startTask(task))}); + } + editMenu.push({title: "Rename", onchange: st5(() => renameTask(task, () => editTask(task, backFn)))}); + editMenu.push({title: "Interval", value: task.interval, min: 10, step: 10, onchange: v => task.interval = v}); + editMenu.push({title: "Incremental Backoff", value: !!task.useBackoff, onchange: v => task.useBackoff = v}); + editMenu.push({title: "DELETE", onchange: st5(() => deleteTask(task, () => editTask(task, backFn), backFn))}); + editMenu.push({title: "Statistics:"}); + editMenu.push({title: "On Task: " + task.affirmCount}); + editMenu.push({title: "Distracted: " + task.distractCount}); + editMenu.push({title: "Unresponsive: " + task.unresponsiveCount}); + editMenu[""] = {title: task.text, back: backFn}; + E.showMenu(editMenu); +} + +/** + * Remove the given task from the task list permanently if the user hits "yes" on the confirmation dialogue. + * @param task The task to delete. + * @param backFn The function to be called when the user cancels. + * @param deleteBackFn The function to be called when the user confirms. + */ +function deleteTask(task, backFn, deleteBackFn) { + E.showPrompt("Delete " + task.text + "?") + .then(shouldDelete => { + if (shouldDelete) { + removeTask(task, allTasks); + deleteBackFn(); + } else { + backFn(); + } + }); +} + +/** + * Change the text of the given task, and then execute the given function. + * @param task + * @param backFn The function to execute after the renaming. Typically to show some previous menu. + * @returns {*} + */ +function renameTask(task, backFn) { + return textInput.input({text: task.text, keyboardMain: keyboardAlpha, keyboardShift: keyboardAlphaShift}) + .then(text => { + task.text = text + save(); + backFn(); + }) +} + +/** + * Get the "menu" that displays a given active task. This may not seem like a menu to users, but it includes swipe + * controls and can sometimes include pressable buttons as well. + * @param task + * @param backFn + * @returns {{setUI: (function(): void), buttons: *[], render: render}} + */ function getTaskMenu(task, backFn) { const d = new Date(); const h = d.getHours(), m = d.getMinutes(); @@ -437,6 +637,13 @@ function getTaskMenu(task, backFn) { }); } +/** + * Given a task, determine the next incomplete task in the task list and return it. Return undefined if there are no + * other incomplete tasks. + * @param task + * @param list + * @returns {undefined|*} + */ function getNextTask(task, list) { const activeList = list.filter(x => (!x.complete || x === task)); const thisTaskPosition = activeList.findIndex(t => t === task); @@ -448,63 +655,14 @@ function getNextTask(task, list) { } /** - * This function is a workaround wrapper for a menu navigation bug. After 'onchange' the menu re-renders itself - * so to avoid graphical glitches we postpone whatever funciton we actually want by 5ms. - * @param fn The function you actually want to call - * @returns {function(): any} The same function wrapped in a setTimeout with a 5ms delay. + * Show the list of tasks in a menu, filtered by the filterFn. Selecting a task in this menu will bring you to that + * task's edit menu. + * @param filterFn + * @param backFn */ -function st5(fn) { - return () => setTimeout(fn, 5); -} - -function editTask(task, backFn) { - nudgeManager.interrupt(); - let editMenu = []; - if (task.complete) { - editMenu.push({title: "Start Task", onchange: st5(() => restartTask(task))}); - editMenu.push({title: "View Task", onchange: st5(() => startTask(task))}); - } else { - editMenu.push({title: "Resume Task", onchange: st5(() => startTask(task))}); - } - editMenu.push({title: "Rename", onchange: st5(() => renameTask(task, () => editTask(task, backFn)))}); - editMenu.push({title: "Interval", value: task.interval, min: 10, step: 10, onchange: v => task.interval = v}); - editMenu.push({title: "Incremental Backoff", value: !!task.useBackoff, onchange: v => task.useBackoff = v}); - editMenu.push({title: "DELETE", onchange: st5(() => deleteTask(task, () => editTask(task, backFn), backFn))}); - editMenu.push({title: "Statistics:"}); - editMenu.push({title: "On Task: " + task.affirmCount}); - editMenu.push({title: "Distracted: " + task.distractCount}); - editMenu.push({title: "Unresponsive: " + task.unresponsiveCount}); - editMenu[""] = {title: task.text, back: backFn}; - E.showMenu(editMenu); -} - -function deleteTask(task, backFn, deleteBackFn) { - E.showPrompt("Delete " + task.text + "?") - .then(shouldDelete => { - if (shouldDelete) { - const foundIndex = allTasks.findIndex(t => t === task); - if (foundIndex !== -1) { - allTasks.splice(foundIndex, 1); - } - deleteBackFn(); - } else { - backFn(); - } - }); -} - -function renameTask(task, backFn) { - return textInput.input({text: task.text, keyboardMain: keyboardAlpha}) - .then(text => { - task.text = text - save(); - backFn(); - }) -} - function showTaskList(filterFn, backFn) { let taskMenu = []; - const list = allTasks.filter(filterFn); + const list = allTasks.filter(filterFn); taskMenu = taskMenu.concat(list.map(task => { return { // Workaround - navigation has phantom buttons rendered with E.showMenu unless you delay slightly. @@ -515,10 +673,12 @@ function showTaskList(filterFn, backFn) { E.showMenu(taskMenu); } +/** + * Show the menu for editing settings and tasks. + * @param backFn + */ function showSettingsMenu(backFn) { - const completeTasks = allTasks.filter(task => task.complete); - const incompleteTasks = allTasks.filter(task => !task.complete); - const settingsMenu = { + const settingsMenu = { "" : {title: "Manage", back: backFn}, "Pending Tasks" : () => showTaskList(task => !task.complete, () => showSettingsMenu(backFn)), "Completed Tasks": () => showTaskList(task => task.complete, () => showSettingsMenu(backFn)), @@ -527,7 +687,6 @@ function showSettingsMenu(backFn) { } E.showMenu(settingsMenu); } - const mainMenu = createMenu({ title : "Working Memory", items: [ {text: "New Task", size: 2, callback: () => newTask("")}, { From f31f547b6e9383d666d73de710451dbd3a6e297c Mon Sep 17 00:00:00 2001 From: Philip Andresen Date: Tue, 13 Jun 2023 15:45:24 -0400 Subject: [PATCH 10/11] FIx ChangeLog name, add shortName to metadata --- apps/wrkmem/{Changelog => ChangeLog} | 0 apps/wrkmem/metadata.json | 1 + 2 files changed, 1 insertion(+) rename apps/wrkmem/{Changelog => ChangeLog} (100%) diff --git a/apps/wrkmem/Changelog b/apps/wrkmem/ChangeLog similarity index 100% rename from apps/wrkmem/Changelog rename to apps/wrkmem/ChangeLog diff --git a/apps/wrkmem/metadata.json b/apps/wrkmem/metadata.json index 040fbc750..1525ba64e 100644 --- a/apps/wrkmem/metadata.json +++ b/apps/wrkmem/metadata.json @@ -1,6 +1,7 @@ { "id" : "wrkmem", "name" : "Working Memory Helper", + "shortName" : "Work Mem", "version" : "1.00", "description" : "Externalize your working memory to help stay on task.", "dependencies" : {"textinput": "type"}, From f947105677ea2bcdece8317bfdbaee6fae0d9a0e Mon Sep 17 00:00:00 2001 From: Philip Andresen Date: Tue, 13 Jun 2023 18:26:28 -0400 Subject: [PATCH 11/11] remove workaround fixed by a0e2d92 --- apps/wrkmem/app.js | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/apps/wrkmem/app.js b/apps/wrkmem/app.js index 3f283ca8a..cce1438bb 100644 --- a/apps/wrkmem/app.js +++ b/apps/wrkmem/app.js @@ -75,16 +75,6 @@ function save() { .writeJSON("wrkmem.json", savedData); } -/** - * This function is a workaround wrapper for a menu navigation bug. After 'onchange' the menu re-renders itself - * so to avoid graphical glitches we postpone whatever funciton we actually want by 5ms. - * @param fn The function you actually want to call - * @returns {function(): any} The same function wrapped in a setTimeout with a 5ms delay. - */ -function st5(fn) { - return () => setTimeout(fn, 5); -} - /** * Given a position and set of dimensions, create a button object that represents a rectangle in space containing text * and some associated functionality. @@ -543,15 +533,15 @@ function editTask(task, backFn) { nudgeManager.interrupt(); let editMenu = []; if (task.complete) { - editMenu.push({title: "Start Task", onchange: st5(() => restartTask(task))}); - editMenu.push({title: "View Task", onchange: st5(() => startTask(task))}); + editMenu.push({title: "Start Task", onchange: () => restartTask(task)}); + editMenu.push({title: "View Task", onchange: () => startTask(task)}); } else { - editMenu.push({title: "Resume Task", onchange: st5(() => startTask(task))}); + editMenu.push({title: "Resume Task", onchange: () => startTask(task)}); } - editMenu.push({title: "Rename", onchange: st5(() => renameTask(task, () => editTask(task, backFn)))}); + editMenu.push({title: "Rename", onchange: () => renameTask(task, () => editTask(task, backFn))}); editMenu.push({title: "Interval", value: task.interval, min: 10, step: 10, onchange: v => task.interval = v}); editMenu.push({title: "Incremental Backoff", value: !!task.useBackoff, onchange: v => task.useBackoff = v}); - editMenu.push({title: "DELETE", onchange: st5(() => deleteTask(task, () => editTask(task, backFn), backFn))}); + editMenu.push({title: "DELETE", onchange: () => deleteTask(task, () => editTask(task, backFn), backFn)}); editMenu.push({title: "Statistics:"}); editMenu.push({title: "On Task: " + task.affirmCount}); editMenu.push({title: "Distracted: " + task.distractCount}); @@ -666,7 +656,7 @@ function showTaskList(filterFn, backFn) { taskMenu = taskMenu.concat(list.map(task => { return { // Workaround - navigation has phantom buttons rendered with E.showMenu unless you delay slightly. - title: task.text, onchange: st5(() => editTask(task, () => showTaskList(filterFn, backFn))) + title: task.text, onchange: () => editTask(task, () => showTaskList(filterFn, backFn)) } })) taskMenu[""] = {title: "Tasks", back: backFn};