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..bad3914c9 --- /dev/null +++ b/apps/wrkmem/README.md @@ -0,0 +1,60 @@ +# 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. 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 warrant 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/wrkmem/app.js b/apps/wrkmem/app.js new file mode 100644 index 000000000..cce1438bb --- /dev/null +++ b/apps/wrkmem/app.js @@ -0,0 +1,688 @@ +const textInput = require("textinput"); + +g.reset(); +g.clearRect(Bangle.appRect); +E.showMessage("Loading ... "); +Bangle.loadWidgets(); +Bangle.drawWidgets(); + +const localTaskFile = "wrkmem.json"; +const savedData = { + tasks: [], keyboardAlpha: undefined, settings: {textOutlines: true, noWordBreaks: true} +}; + +Object.assign(savedData, require("Storage") +.readJSON(localTaskFile, true) || {}); + +let currentMenu; + +const allTasks = savedData.tasks; +const SWIPE = { + LEFT: 2, RIGHT: 0, UP: 3, DOWN: 1, +} + +/** + * 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); + 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 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. + if (this.taskTimeout) clearTimeout(this.taskTimeout); + this.responseTimeout = setTimeout(defaultFn, 15000); + }, +} + +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); +} + +/** + * 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; + 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 + }; +} + +/** + * 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; + 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"); + 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); + 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") + .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(" ") && savedData.settings.noWordBreaks) { + 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]; +} + +/** + * 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; + 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}; +} + +/** + * 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); + 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}); +} + +/** + * 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; + 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 || []; + // 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); + 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 = []; + // currentGrid tracks what grid square we are covering. Any item may cover multiple grid squares. + let currentGrid = 0; + 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; + 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)); + const btnFunc = options.backFn; + return { + buttons, render, setUI: () => Bangle.setUI({mode: "custom", touch: touchFunc, swipe: swipeFunc, btn: btnFunc}) + }; +} + +/** + * 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.reset(); + g.clearRect(Bangle.appRect); + currentMenu = menu; + menu.render(); + menu.setUI(); + Bangle.drawWidgets(); +} + +/** + * 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, keyboardShift: keyboardAlphaShift}) + .then(text => { + if (!text) { + setMenu(mainMenu); + } + const task = createTask(text) + allTasks.unshift(task); + save(); + startTask(task); + }) +} + +/** + * 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(); + const onPressBack = () => { + nudgeManager.interrupt(); + setMenu(mainMenu) + } + 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(() => { + 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: 1, callback: () => affirmOnTask(task)}, { + text: "Distracted", size: 1, callback: () => affirmDistracted(task) + } + ], isHorizontal: false + }); + setMenu(nudgeMenu); + 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)); + task.unresponsiveCount++; + task.backoffIndex = Math.max(0, task.backoffIndex - 1); + 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(() => { + 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); + save(); + 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); + allTasks.unshift(task); + save(); + 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) { + list.splice(taskIndex, 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 { + text, + affirmCount : 0, + distractCount : 0, + unresponsiveCount: 0, + interval : 30, + backoffIndex : 1, + incrementalBackoffSet, + complete : false, + useBackoff : true + }; +} + +/** + * 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: () => restartTask(task)}); + editMenu.push({title: "View Task", onchange: () => startTask(task)}); + } else { + editMenu.push({title: "Resume Task", onchange: () => startTask(task)}); + } + 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: () => 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(); + const time = h + ":" + m.toString() + .padStart(2, 0); + const taskSwipeControls = [ + 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(() => true, () => startTask(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, 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, title: time, 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); + let nextTask = activeList[0]; + if (thisTaskPosition !== -1 && activeList[thisTaskPosition + 1]) { + nextTask = activeList[thisTaskPosition + 1]; + } + return nextTask === task ? undefined : nextTask; +} + +/** + * 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 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: () => editTask(task, () => showTaskList(filterFn, backFn)) + } + })) + taskMenu[""] = {title: "Tasks", back: backFn}; + E.showMenu(taskMenu); +} + +/** + * Show the menu for editing settings and tasks. + * @param backFn + */ +function showSettingsMenu(backFn) { + const settingsMenu = { + "" : {title: "Manage", back: 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} + } + E.showMenu(settingsMenu); +} +const mainMenu = createMenu({ + title : "Working Memory", items: [ + {text: "New Task", size: 2, callback: () => newTask("")}, { + text: "Manage", size: 1, callback: () => showSettingsMenu(() => setMenu(mainMenu)) + } + ], isHorizontal: false +}); + +setMenu(mainMenu); diff --git a/apps/wrkmem/icon.png b/apps/wrkmem/icon.png new file mode 100644 index 000000000..23e1df523 Binary files /dev/null and b/apps/wrkmem/icon.png differ diff --git a/apps/wrkmem/metadata.json b/apps/wrkmem/metadata.json new file mode 100644 index 000000000..1525ba64e --- /dev/null +++ b/apps/wrkmem/metadata.json @@ -0,0 +1,27 @@ +{ + "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"}, + "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 000000000..7145bc8a7 Binary files /dev/null and b/apps/wrkmem/screenshot.png differ diff --git a/apps/wrkmem/screenshot2.png b/apps/wrkmem/screenshot2.png new file mode 100644 index 000000000..3a61529e3 Binary files /dev/null and b/apps/wrkmem/screenshot2.png differ diff --git a/apps/wrkmem/screenshot3.png b/apps/wrkmem/screenshot3.png new file mode 100644 index 000000000..30776d332 Binary files /dev/null and b/apps/wrkmem/screenshot3.png differ diff --git a/apps/wrkmem/screenshot4.png b/apps/wrkmem/screenshot4.png new file mode 100644 index 000000000..582c2c92b Binary files /dev/null and b/apps/wrkmem/screenshot4.png differ diff --git a/apps/wrkmem/screenshot5.png b/apps/wrkmem/screenshot5.png new file mode 100644 index 000000000..aa9165e38 Binary files /dev/null and b/apps/wrkmem/screenshot5.png differ diff --git a/apps/wrkmem/screenshot6.png b/apps/wrkmem/screenshot6.png new file mode 100644 index 000000000..fd3e1a36b Binary files /dev/null and b/apps/wrkmem/screenshot6.png differ